diff --git a/README.md b/README.md index d5f7ade..b56db57 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 regular events by event ID - - [x] 'a' tags: Delete addressable events by address (kind:pubkey:d-identifier) + - [x] `e` tags: delete events by id + - [x] `a` tags: delete replaceable / addressable events by `kind:pubkey:d` - [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/relay/client_connection.py b/relay/client_connection.py index fae522e..1480e2a 100644 --- a/relay/client_connection.py +++ b/relay/client_connection.py @@ -229,66 +229,61 @@ class NostrClientConnection: await self.websocket.send_text(json.dumps(data)) async def _handle_delete_event(self, event: NostrEvent): - # NIP 09 - Handle both regular events (e tags) and parameterized replaceable events (a tags) + """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] = [] - # 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.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 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 += [ + 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 b6343e0..dc23234 100644 --- a/relay/event.py +++ b/relay/event.py @@ -120,5 +120,8 @@ 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 a01aaf6..dd12352 100644 --- a/relay/filter.py +++ b/relay/filter.py @@ -64,6 +64,10 @@ 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_nip09.py b/tests/test_nip09.py new file mode 100644 index 0000000..04f4d57 --- /dev/null +++ b/tests/test_nip09.py @@ -0,0 +1,137 @@ +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"}