feat(relay): add NIP-37 draft wrap support (kind:31234)

Add kind:31234 as an author-only, channel-less, parameterized-replaceable
event kind for encrypted draft wraps per NIP-37.

Privacy enforcement spans every relay read path:
- WS REQ: AUTHOR_ONLY_KINDS gate closes the subscription with
  restricted: for any requester who isn't the author
- WS COUNT: same gate applied before the count query executes
- HTTP bridge /query + /count: post-filter and guard use AUTHOR_ONLY_KINDS
- Live fan-out: AUTHOR_ONLY_KINDS check in dispatch_persistent_event_inner
  prevents draft events from being pushed to non-author subscribers
- FTS (NIP-50): migration 0007 sets search_tsv = NULL for kind:31234,
  making drafts storage-level unsearchable

Ingest validation (validate_draft_wrap_envelope):
- Exactly one non-empty d tag (any bounded value; relay is grammar-agnostic)
- Exactly one k tag with canonical u16 decimal (no leading zeros, fits u16)
- No h or p outer tags (compose context belongs in encrypted payload only)
- Content: empty string (tombstone) or NIP-44 v2 ciphertext shape check
- Optional expiration: at most one, decimal, strictly future, ≤ safe integer

NIP-11 now advertises NIP-37. NIP-40 is intentionally not advertised
because Buzz does not yet suppress expired rows on read.

Schema migration 0007 extends the search_tsv generated column exclusion
list with kind 31234.

New tests:
- 23 unit tests for validate_draft_wrap_envelope in ingest.rs covering
  every acceptance and rejection path
- Comprehensive E2E test suite in e2e_nip37_draft.rs covering write
  validation, NIP-01 replacement ordering, tombstone persistence,
  author-only REQ/COUNT/HTTP, kindless/mixed filter privacy, known-d
  privacy tripwires, live fan-out isolation, and NIP-11 advertisement

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-07-14 00:19:32 -04:00
committed by Will Pfleger
co-authored by Will Pfleger
parent 458c7f9154
commit 79a9a89a4d
10 changed files with 1757 additions and 26 deletions
+2 -2
View File
@@ -206,9 +206,9 @@ CREATE TABLE events (
-- Privacy: encrypted/private routing wrappers and p-gated membership notices
-- must never be discoverable through NIP-50 full-text search. NULL tsvector
-- never matches `@@`.
-- Keep in sync with migrations (final state: 0001 + 0005_agent_turn_metric_fts).
-- Keep in sync with migrations (final state: 0001 + 0005_agent_turn_metric_fts + 0006_moderation + 0007_draft_wrap_fts).
search_tsv TSVECTOR GENERATED ALWAYS AS (
CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101, 44200) THEN NULL::tsvector
CASE WHEN kind IN (1059, 30300, 30622, 31234, 44100, 44101, 44200) THEN NULL::tsvector
ELSE to_tsvector('simple', content)
END
) STORED,