fix(nip09): honour a tags in deletion requests

A kind-5 request could only name events by `e` tag. Worse, one that
carried `a` tags and no `e` tags built a filter with an empty id list,
which matched every event by that author and marked them all deleted.

Handle `a` tags per NIP-09: parse `kind:pubkey:d` (the `d` value may
contain ':'), require the pubkey to be the request author and the kind
to be replaceable or addressable, and remove only versions up to the
request's `created_at` so a later re-publication survives. An empty
`d` addresses a replaceable kind.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tbyw6FwjhEJg3gHfPHxWt
(cherry picked from commit e695f9c66881d3d520d40f0cd3e9c95d9435938c)
This commit is contained in:
Patrick Mulligan 2026-09-13 17:28:11 +02:00 • committed by Padreug
commit fd19acff97
5 changed files with 191 additions and 52 deletions

137
tests/test_nip09.py Normal file
View file

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