diff --git a/README.md b/README.md index b56db57..d5f7ade 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ - [x] **NIP-04**: Encrypted Direct Message - if `AUTH` enabled: send only to the intended target - [x] **NIP-09**: Event Deletion - - [x] `e` tags: delete events by id - - [x] `a` tags: delete replaceable / addressable events by `kind:pubkey:d` + - [x] 'e' tags: Delete regular events by event ID + - [x] 'a' tags: Delete addressable events by address (kind:pubkey:d-identifier) - [x] **NIP-11**: Relay Information Document - > **Note**: the endpoint is NOT on the root level of the domain. It also includes a path (eg https://lnbits.link/nostrrelay/) - [ ] **NIP-12**: Generic Tag Queries diff --git a/config.json b/config.json index 036456c..6831512 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "name": "Nostr Relay", - "version": "1.1.0-aio.4", + "version": "1.1.0-aio.3", "short_description": "One click launch your own relay!", "tile": "/nostrrelay/static/image/nostrrelay.png", "min_lnbits_version": "1.4.0", diff --git a/relay/client_connection.py b/relay/client_connection.py index f6eb742..fae522e 100644 --- a/relay/client_connection.py +++ b/relay/client_connection.py @@ -113,8 +113,7 @@ class NostrClientConnection: await self.broadcast_event(self, e) else: logger.warning( - "[NOSTRRELAY CLIENT] ❌ No broadcast_event callback available " - f"for event {e.id}" + f"[NOSTRRELAY CLIENT] ❌ No broadcast_event callback available for event {e.id}" ) async def _handle_message(self, data: list) -> list: @@ -230,61 +229,66 @@ class NostrClientConnection: await self.websocket.send_text(json.dumps(data)) async def _handle_delete_event(self, event: NostrEvent): - """NIP-09: `e` tags name events by id, `a` tags name replaceable or - addressable events by `kind:pubkey:d`. Only the author's own events - are touched; for `a` tags only versions up to the deletion request's - `created_at` are removed, so a later re-publication survives.""" - ids_to_delete: list[str] = [] + # NIP 09 - Handle both regular events (e tags) and parameterized replaceable events (a tags) + # Get event IDs from 'e' tags (for regular events) event_ids = [t[1] for t in event.tags if t[0] == "e"] + + # Get event addresses from 'a' tags (for parameterized replaceable events) + event_addresses = [t[1] for t in event.tags if t[0] == "a"] + + ids_to_delete = [] + + # Handle regular event deletions (e tags) if event_ids: nostr_filter = NostrFilter(authors=[event.pubkey], ids=event_ids) events_to_delete = await get_events(self.relay_id, nostr_filter, False) - ids_to_delete += [e.id for e in events_to_delete if not e.is_delete_event] - - for address in (t[1] for t in event.tags if t[0] == "a"): - address_filter = self._address_filter(address, event.pubkey) - if not address_filter: - logger.debug(f"NIP-09: ignoring address '{address}'") - continue - # An addressable kind with an empty `d` names the version whose - # `d` tag is empty or missing, which no tag join can express, so - # fetch tags and filter here. - empty_d = address_filter.is_addressable and not address_filter.d - events_to_delete = await get_events( - self.relay_id, address_filter, include_tags=empty_d + ids_to_delete.extend( + [e.id for e in events_to_delete if not e.is_delete_event] ) - ids_to_delete += [ - e.id - for e in events_to_delete - if e.created_at <= event.created_at - and (not empty_d or not e.has_tag_value_other_than("d", "")) - ] + # Handle parameterized replaceable event deletions (a tags) + if event_addresses: + for addr in event_addresses: + # Parse address format: kind:pubkey:d-tag + parts = addr.split(":") + if len(parts) == 3: + kind_str, addr_pubkey, d_tag = parts + try: + kind = int(kind_str) + # Only delete if the address pubkey matches the deletion event author + if addr_pubkey == event.pubkey: + # NOTE: Use "#d" alias, not "d" directly (Pydantic Field alias) + nostr_filter = NostrFilter( + authors=[addr_pubkey], + kinds=[kind], + **{"#d": [d_tag]}, # Use alias to set d field + ) + events_to_delete = await get_events( + self.relay_id, nostr_filter, False + ) + ids_to_delete.extend( + [ + e.id + for e in events_to_delete + if not e.is_delete_event + ] + ) + else: + logger.warning( + f"Deletion request pubkey mismatch: {addr_pubkey} != {event.pubkey}" + ) + except ValueError: + logger.warning(f"Invalid kind in address: {addr}") + else: + logger.warning( + f"Invalid address format (expected kind:pubkey:d-tag): {addr}" + ) + + # Only mark events as deleted if we found specific IDs if ids_to_delete: await mark_events_deleted(self.relay_id, NostrFilter(ids=ids_to_delete)) - @staticmethod - def _address_filter(address: str, author: str) -> NostrFilter | None: - # `kind:pubkey:d`; the `d` value may itself contain ':'. - parts = address.split(":", 2) - if len(parts) != 3 or not parts[0].isdigit(): - return None - kind, pubkey, d_tag = int(parts[0]), parts[1], parts[2] - if pubkey != author: - return None - # NIP-01: replaceable are 0, 3 and 10000-19999; addressable 30000-39999. - replaceable = kind in (0, 3) or 10000 <= kind < 20000 - addressable = 30000 <= kind < 40000 - if not (replaceable or addressable): - return None - if d_tag: - # `d` is a Pydantic alias field; it must be set through the alias. - return NostrFilter( - authors=[author], kinds=[kind], **{"#d": [d_tag]} # type: ignore[arg-type] - ) - return NostrFilter(authors=[author], kinds=[kind]) - async def _handle_request( self, subscription_id: str, nostr_filter: NostrFilter ) -> list: diff --git a/relay/event.py b/relay/event.py index ed9054f..b6343e0 100644 --- a/relay/event.py +++ b/relay/event.py @@ -26,17 +26,10 @@ class NostrEvent(BaseModel): size: int = 0 def nostr_dict(self) -> dict: - # Only the NIP-01 fields go on the wire; storage columns such as - # relay_id, publisher and size must not leak into EVENT responses. - return { - "id": self.id, - "pubkey": self.pubkey, - "created_at": self.created_at, - "kind": self.kind, - "tags": self.tags, - "content": self.content, - "sig": self.sig, - } + _nostr_dict = dict(self) + _nostr_dict.pop("relay_id") + _nostr_dict.pop("publisher") + return _nostr_dict def serialize(self) -> list: return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content] @@ -127,8 +120,5 @@ class NostrEvent(BaseModel): def has_tag_value(self, tag_name: str, tag_value: str) -> bool: return tag_value in self.tag_values(tag_name) - def has_tag_value_other_than(self, tag_name: str, value: str) -> bool: - return any(t[0] == tag_name and t[1] != value for t in self.tags if len(t) > 1) - def is_direct_message_for_pubkey(self, pubkey: str) -> bool: return self.is_direct_message and self.has_tag_value("p", pubkey) diff --git a/relay/filter.py b/relay/filter.py index dd12352..a01aaf6 100644 --- a/relay/filter.py +++ b/relay/filter.py @@ -64,10 +64,6 @@ class NostrFilter(BaseModel): and (not self.until) ) - @property - def is_addressable(self) -> bool: - return all(30000 <= k < 40000 for k in self.kinds) and len(self.kinds) > 0 - def enforce_limit(self, limit: int): if not self.limit or self.limit > limit: self.limit = limit diff --git a/tests/test_clients.py b/tests/test_clients.py index 5d1100a..e65bc60 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -37,18 +37,6 @@ class MockWebSocket(WebSocket): async def send_text(self, data: str): self.sent_messages.append(data) - async def wait_for_messages(self, count: int, timeout: float = 5.0): - """Wait until at least `count` messages were sent to this socket.""" - loop = asyncio.get_running_loop() - deadline = loop.time() + timeout - while len(self.sent_messages) < count: - if loop.time() > deadline: - raise AssertionError( - f"Expected {count} messages, got {len(self.sent_messages)} " - f"after {timeout}s: {self.sent_messages}" - ) - await asyncio.sleep(0.01) - async def wire_mock_data(self, data: dict): await self.fake_wire.put(dumps(data)) @@ -110,7 +98,7 @@ async def alice_wires_meta_and_post01(ws_alice: MockWebSocket): await ws_alice.wire_mock_data(alice["post01"]) await ws_alice.wire_mock_data(alice["post01"]) await ws_alice.wire_mock_data(alice["meta_update"]) - await ws_alice.wait_for_messages(4) + await asyncio.sleep(0.5) assert ( len(ws_alice.sent_messages) == 4 @@ -125,6 +113,8 @@ async def alice_wires_meta_and_post01(ws_alice: MockWebSocket): alice["meta_update_response"] ), "Alice: Expected confirmation for meta update" + await asyncio.sleep(0.1) + async def bob_wires_meta_and_folows_alice(ws_bob: MockWebSocket): ws_bob.sent_messages.clear() @@ -132,7 +122,8 @@ async def bob_wires_meta_and_folows_alice(ws_bob: MockWebSocket): await ws_bob.wire_mock_data(bob["meta"]) await ws_bob.wire_mock_data(bob["request_meta_alice"]) await ws_bob.wire_mock_data(bob["request_posts_alice"]) - await ws_bob.wait_for_messages(5) + + await asyncio.sleep(0.5) assert len(ws_bob.sent_messages) == 5, "Bob: Expected 5 confirmations to be sent" assert ws_bob.sent_messages[0] == dumps( @@ -158,9 +149,9 @@ async def bob_wires_contact_list(ws_alice: MockWebSocket, ws_bob: MockWebSocket) await ws_bob.wire_mock_data(bob["contact_list_create"]) await ws_bob.wire_mock_data(bob["contact_list_update"]) - await ws_bob.wait_for_messages(2) + await asyncio.sleep(0.1) await ws_alice.wire_mock_data(alice["subscribe_to_bob_contact_list"]) - await ws_alice.wait_for_messages(2) + await asyncio.sleep(0.1) assert ( len(ws_bob.sent_messages) == 2 @@ -190,8 +181,7 @@ async def alice_wires_post02_____bob_is_notified( ws_alice.sent_messages.clear() await ws_alice.wire_mock_data(alice["post02"]) - await ws_alice.wait_for_messages(1) - await ws_bob.wait_for_messages(1) + await asyncio.sleep(0.1) assert ws_alice.sent_messages[0] == dumps( alice["post02_response_ok"] @@ -208,9 +198,9 @@ async def bob_likes_post01_____alice_subscribes_and_receives_notifications( ws_bob.sent_messages.clear() await ws_bob.wire_mock_data(bob["like_post01"]) - await ws_bob.wait_for_messages(1) + await asyncio.sleep(0.1) await ws_alice.wire_mock_data(alice["subscribe_reactions_to_me"]) - await ws_alice.wait_for_messages(2) + await asyncio.sleep(0.1) assert ( len(ws_alice.sent_messages) == 2 @@ -237,8 +227,7 @@ async def bob_likes_and_comments_____alice_receives_notifications( await ws_bob.wire_mock_data(bob["like_post02"]) await ws_bob.wire_mock_data(bob["comment_on_alice_post01"]) - await ws_bob.wait_for_messages(2) - await ws_alice.wait_for_messages(2) + await asyncio.sleep(0.5) assert ( len(ws_bob.sent_messages) == 2 @@ -273,8 +262,7 @@ async def bob_writes_to_alice(ws_alice: MockWebSocket, ws_bob: MockWebSocket): ws_bob.sent_messages.clear() await ws_bob.wire_mock_data(bob["direct_message01"]) - await ws_bob.wait_for_messages(1) - await ws_alice.wait_for_messages(1) + await asyncio.sleep(0.1) assert ( len(ws_bob.sent_messages) == 1 @@ -299,7 +287,7 @@ async def alice_writes_to_bob(ws_alice: MockWebSocket, ws_bob: MockWebSocket): ws_bob.sent_messages.clear() await ws_alice.wire_mock_data(alice["direct_message01"]) - await ws_alice.wait_for_messages(1) + await asyncio.sleep(0.1) assert ( len(ws_alice.sent_messages) == 1 @@ -310,7 +298,7 @@ async def alice_writes_to_bob(ws_alice: MockWebSocket, ws_bob: MockWebSocket): assert len(ws_bob.sent_messages) == 0, "Bob: no subscription, no message" await ws_bob.wire_mock_data(bob["subscribe_to_direct_messages"]) - await ws_bob.wait_for_messages(2) + await asyncio.sleep(0.5) assert ( len(ws_bob.sent_messages) == 2 @@ -333,7 +321,7 @@ async def alice_deletes_post01__bob_is_notified( ): ws_bob.sent_messages.clear() await ws_bob.wire_mock_data(bob["request_posts_alice"]) - await ws_bob.wait_for_messages(3) + await asyncio.sleep(0.1) assert ( len(ws_bob.sent_messages) == 3 ), "Bob: Expected two posts from Alice plus and EOSE" @@ -342,10 +330,9 @@ async def alice_deletes_post01__bob_is_notified( ws_bob.sent_messages.clear() await ws_bob.wire_mock_data(bob["subscribe_to_delete_from_alice"]) - await ws_bob.wait_for_messages(1) + await asyncio.sleep(0.1) await ws_alice.wire_mock_data(alice["delete_post01"]) - await ws_alice.wait_for_messages(1) - await ws_bob.wait_for_messages(2) + await asyncio.sleep(0.1) assert ( len(ws_alice.sent_messages) == 1 @@ -366,7 +353,7 @@ async def alice_deletes_post01__bob_is_notified( ws_bob.sent_messages.clear() await ws_bob.wire_mock_data(bob["request_posts_alice"]) - await ws_bob.wait_for_messages(2) + await asyncio.sleep(0.1) assert ( len(ws_bob.sent_messages) == 2 ), "Bob: Expected one posts from Alice plus and EOSE" diff --git a/tests/test_nip09.py b/tests/test_nip09.py deleted file mode 100644 index 04f4d57..0000000 --- a/tests/test_nip09.py +++ /dev/null @@ -1,137 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from ..crud import create_event, get_events -from ..relay.client_connection import NostrClientConnection -from ..relay.event import NostrEvent -from ..relay.filter import NostrFilter -from ..relay.relay import RelaySpec - -ALICE = "a" * 64 -BOB = "b" * 64 -SIG = "0" * 128 - - -def _connection(relay_id: str) -> NostrClientConnection: - conn = NostrClientConnection(relay_id=relay_id, websocket=MagicMock()) - conn.get_client_config = lambda: RelaySpec() - conn._send_msg = AsyncMock() # type: ignore[method-assign] - return conn - - -def _event(relay_id, id_, pubkey, kind, created_at, tags=None) -> NostrEvent: - return NostrEvent( - id=id_.rjust(64, "0"), - relay_id=relay_id, - publisher=pubkey, - pubkey=pubkey, - created_at=created_at, - kind=kind, - tags=tags or [], - content="", - sig=SIG, - ) - - -async def _remaining(relay_id: str) -> set[str]: - events = await get_events(relay_id, NostrFilter(kinds=[1, 3, 30023]), False) - return {e.id.lstrip("0") or "0" for e in events} - - -@pytest.mark.asyncio -async def test_a_tag_deletes_only_the_addressed_event(): - relay_id = "nip09_a_tag" - await create_event(_event(relay_id, "1", ALICE, 30023, 100, [["d", "one"]])) - await create_event(_event(relay_id, "2", ALICE, 30023, 100, [["d", "two"]])) - await create_event(_event(relay_id, "3", BOB, 30023, 100, [["d", "one"]])) - - await _connection(relay_id)._handle_delete_event( - _event(relay_id, "9", ALICE, 5, 200, [["a", f"30023:{ALICE}:one"]]) - ) - - assert await _remaining(relay_id) == {"2", "3"} - - -@pytest.mark.asyncio -async def test_a_tag_keeps_versions_newer_than_the_request(): - relay_id = "nip09_until" - await create_event(_event(relay_id, "1", ALICE, 30023, 100, [["d", "one"]])) - await create_event(_event(relay_id, "2", ALICE, 30023, 300, [["d", "one"]])) - - await _connection(relay_id)._handle_delete_event( - _event(relay_id, "9", ALICE, 5, 200, [["a", f"30023:{ALICE}:one"]]) - ) - - assert await _remaining(relay_id) == {"2"} - - -@pytest.mark.asyncio -async def test_a_tag_for_replaceable_kind_has_empty_d(): - relay_id = "nip09_replaceable" - await create_event(_event(relay_id, "1", ALICE, 3, 100)) - await create_event(_event(relay_id, "2", ALICE, 1, 100)) - - await _connection(relay_id)._handle_delete_event( - _event(relay_id, "9", ALICE, 5, 200, [["a", f"3:{ALICE}:"]]) - ) - - assert await _remaining(relay_id) == {"2"} - - -@pytest.mark.asyncio -async def test_a_tag_for_non_addressable_kind_is_ignored(): - relay_id = "nip09_kind1" - await create_event(_event(relay_id, "1", ALICE, 1, 100)) - - await _connection(relay_id)._handle_delete_event( - _event(relay_id, "9", ALICE, 5, 200, [["a", f"1:{ALICE}:"]]) - ) - - assert await _remaining(relay_id) == {"1"} - - -@pytest.mark.asyncio -async def test_a_tag_d_value_may_contain_colons(): - relay_id = "nip09_colon" - await create_event(_event(relay_id, "1", ALICE, 30023, 100, [["d", "a:b:c"]])) - - await _connection(relay_id)._handle_delete_event( - _event(relay_id, "9", ALICE, 5, 200, [["a", f"30023:{ALICE}:a:b:c"]]) - ) - - assert await _remaining(relay_id) == set() - - -@pytest.mark.asyncio -async def test_e_tag_deletes_only_own_events(): - relay_id = "nip09_e_tag" - await create_event(_event(relay_id, "1", ALICE, 1, 100)) - await create_event(_event(relay_id, "2", BOB, 1, 100)) - - await _connection(relay_id)._handle_delete_event( - _event( - relay_id, - "9", - ALICE, - 5, - 200, - [["e", "1".rjust(64, "0")], ["e", "2".rjust(64, "0")]], - ) - ) - - assert await _remaining(relay_id) == {"2"} - - -@pytest.mark.asyncio -async def test_a_tag_with_empty_d_on_addressable_kind_is_specific(): - relay_id = "nip09_empty_d" - await create_event(_event(relay_id, "1", ALICE, 30023, 100)) - await create_event(_event(relay_id, "2", ALICE, 30023, 100, [["d", ""]])) - await create_event(_event(relay_id, "3", ALICE, 30023, 100, [["d", "one"]])) - - await _connection(relay_id)._handle_delete_event( - _event(relay_id, "9", ALICE, 5, 200, [["a", f"30023:{ALICE}:"]]) - ) - - assert await _remaining(relay_id) == {"3"} diff --git a/tests/test_req_filters.py b/tests/test_req_filters.py deleted file mode 100644 index 833862b..0000000 --- a/tests/test_req_filters.py +++ /dev/null @@ -1,53 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from ..relay.client_connection import NostrClientConnection -from ..relay.relay import RelaySpec - -RELAY_ID = "relay_req_filters" - - -def _connection(spec: RelaySpec | None = None) -> NostrClientConnection: - conn = NostrClientConnection(relay_id=RELAY_ID, websocket=MagicMock()) - conn.get_client_config = lambda: spec or RelaySpec() - conn._send_msg = AsyncMock() # type: ignore[method-assign] - return conn - - -def _subscriptions(conn: NostrClientConnection) -> list[str | None]: - return [f.subscription_id for f in conn.filters] - - -@pytest.mark.asyncio -async def test_multi_filter_req_keeps_every_filter(): - conn = _connection() - - await conn._handle_message(["REQ", "sub", {"kinds": [1]}, {"kinds": [3]}]) - - assert _subscriptions(conn) == ["sub", "sub"] - assert sorted(f.kinds for f in conn.filters) == [[1], [3]] - - -@pytest.mark.asyncio -async def test_new_req_replaces_the_subscription(): - conn = _connection() - await conn._handle_message(["REQ", "sub", {"kinds": [1]}, {"kinds": [3]}]) - await conn._handle_message(["REQ", "other", {"kinds": [7]}]) - - await conn._handle_message(["REQ", "sub", {"kinds": [0]}]) - - assert _subscriptions(conn) == ["other", "sub"] - assert [f.kinds for f in conn.filters] == [[7], [0]] - - -@pytest.mark.asyncio -async def test_filter_cap_is_enforced(): - conn = _connection(RelaySpec(maxClientFilters=2)) - - await conn._handle_message(["REQ", "a", {"kinds": [1]}]) - await conn._handle_message(["REQ", "b", {"kinds": [1]}]) - responses = await conn._handle_message(["REQ", "c", {"kinds": [1]}]) - - assert _subscriptions(conn) == ["a", "b"] - assert responses == [["NOTICE", "Maximum number of filters (2) exceeded."]]