Skip to content

feat: NIP-50 search, NIP-10 threads, NIP-17 DMs, Sprout DM discovery#74

Merged
tlongwell-block merged 23 commits intomainfrom
nostr-interop-nip50-nip10-nip17
Mar 16, 2026
Merged

feat: NIP-50 search, NIP-10 threads, NIP-17 DMs, Sprout DM discovery#74
tlongwell-block merged 23 commits intomainfrom
nostr-interop-nip50-nip10-nip17

Conversation

@tlongwell-block
Copy link
Collaborator

@tlongwell-block tlongwell-block commented Mar 16, 2026

Summary

Make Sprout usable by standard Nostr clients. Four independently-shippable features, all reusing existing infrastructure — no new databases, no new services, no new tables. Plus iterative review-driven correctness hardening of the historical REQ and search paths.

22 commits · 13 files · +1826/−81 · 15 E2E tests · 93/93 E2E pass · 740+ unit tests pass

Nostr Client (WS/HTTP)                Sprout Relay / Existing Infra
────────────────────────────────────────────────────────────────────────────
REQ {search:"foo"}            ─────►  REQ handler ─────► Typesense
                                         │                 (pre-existing)
                                         └──── batch DB fetch by event IDs

kind:9 + NIP-10 e-tags       ─────►  EVENT handler ───► insert_event_with_thread_metadata
                                                            (pre-existing DB path)

kind:1059 + #p               ─────►  EVENT / REQ handlers
                                         │
                                         ├──── stored in events table (pre-existing)
                                         ├──── gated by #p via event_mentions join
                                         └──── excluded from search/workflows

POST /api/dms + add member   ─────►  existing DM REST paths
                                         │
                                         └──── emit NIP-29 discovery + 44100 membership events

Review process: iterative Codex crossfire: 5 → 7 → 8 → 7 → 7 → 6 → 8 → 7 → 8 → 7 → 8 → 7 → 8 → 8 → 9. Most commits after the initial feature are direct responses to review findings.


What Changed

1. Direct-path NIP support

NIP-50 Search (one-shot, Typesense-backed)

  • Search filters handled as one-shot — not registered as persistent subscriptions (avoids filters_match bug where persistent search subs match all future events)
  • Hits fetched from Typesense, resolved to full events from MySQL, delivered in relevance order
  • Pagination loop (up to 10 pages × 100 per page) ensures post-filtering doesn't silently reduce result count
  • authors, since, until, kinds, and per-filter #h pushed into Typesense filter_by
  • #h values intersected with accessible channels; invalid/inaccessible #h matches nothing (not all)
  • limit == 0 handled explicitly
  • Search intercept runs before #p gating so wildcard-kind searches aren't spuriously blocked

Files: handlers/req.rs, sprout-db/event.rs (batch fetch), sprout-db/lib.rs

NIP-10 Threads (pre-storage ancestry resolution)

  • Scans e tags for NIP-10 root/reply markers before storage
  • Uses existing atomic insert_event_with_thread_metadata — prevents documented race condition
  • Root-only e tags treated as non-threaded context (not replies)
  • Client-supplied root validated against server-resolved ancestry; rejected if they diverge
  • Legacy fallback: handles direct-reply form (["e",<root>,"","reply"]) when parent has no thread_metadata
  • Unknown/cross-channel parents rejected (parity with REST)
  • Thread depth capped at 100

Files: handlers/event.rs

NIP-17 DMs (opaque gift-wrap storage)

  • kind:1059 exempt from pubkey match (ephemeral signing keys by design)
  • Forced to channel_id = None — client h tags ignored (prevents channel-scoped bypass of #p gating)
  • Excluded from Typesense indexing and workflow evaluation
  • P_GATED_KINDS consolidates #p enforcement for kind:44100, 44101, and 1059

Files: handlers/event.rs, handlers/req.rs

Sprout DM Discovery

  • open_dm_handler and add_dm_member_handler emit NIP-29 discovery events on new DM channels
  • kind:39000 metadata includes hidden tag for DMs (client hint, not security boundary)
  • Each participant receives kind:44100 membership notification

Files: api/dms.rs, handlers/side_effects.rs


2. Review-driven correctness fixes

Typesense multi_search

Switched from GET to POST /multi_search. GET has a 4000-char query string limit; with 400+ accessible channels the filter_by exceeds this. Affects both NIP-50 and the existing REST /api/search.

Files: sprout-search/query.rs

Historical REQ hardening

  • Per-filter #h pushdown — extracts channel from each filter's #h tag instead of using the subscription-level channel_id (prevents under-fetch when filters target different channels)
  • Per-filter post-matching instead of OR across all filters (was causing under-fetch)
  • Dedup after acceptance — event that fails filter A remains eligible for filter B (NIP-01 OR semantics)
  • #p pushdown via event_mentions JOIN — gift-wrap/membership queries no longer miss results after 500-row LIMIT
  • Single-author SQL pushdownpubkey pushed into WHERE clause for single-author filters
  • kinds:[] returns zero rows — empty-kinds sentinel now honored by DB layer (was matching all kinds)

Files: handlers/req.rs, sprout-db/event.rs


3. Docs and tests

  • NOSTR.md updated: threads/search/DMs moved from "What Doesn't Work" to "What Works" with nak examples
  • NIP-11 now advertises [1, 10, 11, 17, 25, 29, 42, 50]
  • 15 E2E tests covering all 4 features plus regression tests for:
    • Thread reply hidden from top-level messages
    • Gift wrap not indexed in Typesense (verified via direct Typesense query)
    • Search relevance ordering (first-position assertion)
    • Historical dedup preserves OR semantics across filters
    • kinds:[] returns zero events
  • Existing E2E fixes: max_subscriptions assertion (1024), search indexing delay (2s)

Testing

Suite Result
e2e_nostr_interop (new) 15/15 ✅
e2e_relay 27/27 ✅
e2e_rest_api 51/51 ✅
e2e_mcp 14/14 ✅
Unit tests (workspace) 740+ pass, 0 fail

Live testing

  • nak CLI: kind:9 send/recv, NIP-50 search, NIP-10 thread replies, group discovery
  • ACP round-trip: user → relay → ACP harness → goose agent → MCP → relay → reply visible (echo: nak-e2e-...)

What we're NOT doing

  • NIP-04 / NIP-44 DM support — NIP-17 only
  • kind:10050 relay-list — requires generic replaceable-event semantics
  • Proxied NIP-50 — direct path only
  • Live/persistent search subscriptions — one-shot by design
  • Bridging Sprout DMs ↔ NIP-17 DMs — separate systems

Phase 1 - NIP-50 Search:
- One-shot search REQs via Typesense (not registered as persistent subscriptions)
- Relevance-sorted results (sort_by: None = Typesense text_match default)
- Channel-scoped access control (belt-and-suspenders: Typesense + delivery loop)
- Cross-filter dedup, per-filter NIP-01 post-filtering
- Mixed search/non-search filters rejected with CLOSED
- New get_events_by_ids batch fetch in sprout-db

Phase 2 - NIP-10 Threads:
- Pre-storage ancestry resolution from e-tag root/reply markers
- Atomic insert_event_with_thread_metadata for WS-submitted replies
- Strict mode: unknown parents rejected (parity with REST)
- Cross-channel parent validation
- Broadcast tag support

Phase 3 - NIP-17 DMs:
- kind:1059 gift wraps exempt from pubkey match (ephemeral signing keys)
- Gift wraps excluded from search indexing and workflow evaluation
- #p AUTH-gating for kind:1059 delivery (same pattern as kind:44100/44101)

Phase 4 - Sprout DM Discovery:
- emit_group_discovery_events from both DM creation paths
- emit_membership_notification for each DM participant
- hidden tag on kind:39000 for DM channels

NIP-11: advertise NIPs 10, 17, 50
Crossfire round 1: codex 4/10, opus 8/10. All critical issues fixed:

Security (critical):
- Force channel_id=None for kind:1059 gift wraps — prevents channel-scoped
  storage that would bypass #p AUTH-gating (codex finding #1)

Correctness:
- NIP-50 pagination loop — keep fetching Typesense pages until limit met
  or result set exhausted, capped at MAX_SEARCH_PAGES=5 (codex finding #2)
- Push authors/since/until to Typesense filter_by — post-filtering is now
  a correction step, not the primary filter (codex + opus suggestion)
- NIP-10 root tag validation — reject events where client-supplied root
  diverges from server-resolved ancestry (codex finding #3)

Clarity:
- Consolidate #p gating into single P_GATED_KINDS check (opus suggestion #7)
- filter.clone() → std::slice::from_ref(filter) (opus suggestion #1)
- Remove no-op get_events_by_ids test, add debug_assert (opus #3, #5)
Crossfire round 2 finding: seen_ids.insert() ran before filters_match(),
so an event consumed by filter A (but failing A's NIP-01 constraints)
was incorrectly suppressed for filter B. Moved dedup to after all
acceptance checks — an event that fails one filter remains eligible
for others, preserving NIP-01 OR semantics across search filters.
Crossfire round 3 fixes:

Correctness:
- Push #h channel scope into Typesense filter_by per-filter instead of
  always using the full accessible-channel set. Prevents cross-channel
  hits from consuming pagination budget (codex R3 finding)

Safety:
- Thread depth limit (max 100) prevents deeply nested thread abuse

Style:
- cargo fmt applied across all changed files
Prevents sending per_page=0 to Typesense which would produce a
backend error instead of a clean empty result + EOSE.
10 tests covering all 4 phases, all passing against live relay:

NIP-50 Search (3):
- Search returns results sorted by relevance then EOSE (one-shot)
- Mixed search + non-search filters rejected with CLOSED
- Empty search results return EOSE with no events

NIP-10 Threads (3):
- WS reply with NIP-10 e-tags creates thread_metadata (queryable via REST)
- Unknown parent rejected with OK false
- Root tag mismatch rejected with OK false

NIP-17 DMs (3):
- Gift wrap (kind:1059) accepted despite ephemeral pubkey mismatch
- Gift wrap subscription without #p filter rejected with CLOSED
- Gift wrap delivered to recipient via #p-filtered subscription

DM Discovery (1):
- DM creation emits kind:39000 with hidden+private tags
  and kind:44100 membership notification
- Switch Typesense search from GET to POST /multi_search endpoint.
  GET has a 4000-char query string limit; with 400+ accessible channels
  the filter_by string exceeds this, causing silent search failures.
- Fix test_nip11_relay_info: expect max_subscriptions=1024 (matches relay)
- Fix test_subscription_limit_enforced: open 1024 subs (matches relay limit)
- Fix test_search_returns_indexed_event: increase indexing wait to 2s

All E2E suites now pass: 27/27 relay, 51/51 REST API, 10/10 nostr interop.
- What Works (Path 1): add NIP-50 search, NIP-10 threads, NIP-17 DMs
  (gift wrap), DM discovery rows
- What Doesn't Work (Path 1): remove Threads and NIP-50 search (now
  implemented); update DMs to reflect NIP-17 partial support
- Sending Messages: add nak examples for NIP-50 search, NIP-10 thread
  replies, NIP-17 gift wrap fetch
- Tested Clients (Direct): add E2E nostr interop row (10 tests); update
  nak row to 'Manual (verified)' with search/threads/discovery
- What Doesn't Work (Path 2): update NIP-50 and NIP-10 entries to
  reflect direct-path availability
… legacy thread compat

Three issues from codex final PR review (5/10 → addressed):

1. Search REQs without 'kinds' were rejected by #p gating before reaching
   the NIP-50 one-shot path. Moved search intercept before #p gate — search
   never delivers gift wraps (not indexed) or membership notifications.

2. Pagination could under-return with aggressive post-filtering. Increased
   MAX_SEARCH_PAGES from 5→10 and per_page fixed at 100 regardless of limit,
   giving 1000 raw hits of headroom before the ceiling bites.

3. Legacy WS replies (pre-upgrade, have e-tags but no thread_metadata) caused
   nested reply rejection. Now falls back to parsing the parent event's own
   e-tags to find its root when thread_metadata is missing.

Also: NOSTR.md kind:39000 discovery table now mentions 'hidden' tag for DMs.
Codex review (7/10) found the legacy thread fallback only checked for
'root' marker in parent e-tags, but Sprout's REST emitter uses a single
["e", <root>, "", "reply"] tag for direct replies (no separate
'root' tag). Now checks 'reply' marker as fallback when 'root' is absent.
…n, root timestamp

1. Intersect user-supplied #h values with accessible_channels before
   building Typesense filter — prevents inaccessible/malformed channel
   IDs from consuming pagination budget.

2. Omit sort_by and filter_by from Typesense multi_search body when
   None — avoids sending empty strings that depend on Typesense
   accepting them.

3. Look up actual root event timestamp in legacy thread fallback
   instead of using parent_created as approximation.
Codex 8/10 flagged that filter_to_query_params only pushes channel_id,
kinds, since, until into SQL — authors/tags are post-filtered after the
500-row LIMIT truncation. For single-author filters (the common case for
gift-wrap and membership notification queries), push the pubkey into the
SQL WHERE clause so the DB returns the right rows before truncation.
Codex 7/10 found two pre-existing issues in the historical delivery path
that our new features make worse:

1. Historical post-filter used OR across all filters (filters_match(&filters))
   instead of per-filter matching. A row from query A could pass because it
   matched filter B, consuming B's dedup slot. Changed to per-filter matching
   via std::slice::from_ref(filter) — same pattern as the NIP-50 search path.

2. #p tag was never pushed into SQL. For gift-wrap queries like
   {kinds:[1059], #p:[me]}, the DB returned the 500 newest kind:1059 events
   regardless of recipient, then post-filtered — silently missing older events
   for the caller. Added p_tag_hex field to EventQuery that joins against the
   indexed event_mentions table when set.
… reply

Codex 7/10 found two issues:

1. NIP-50 search: when all #h values are invalid/inaccessible, the code
   fell back to all_channels_filter (broadening). Now skips the filter
   entirely (match nothing), preserving channel-scoped semantics.

2. NIP-10: root-only e-tag (no reply marker) was treated as a direct
   reply, which would incorrectly create thread_metadata and hide the
   event from top-level message queries. Now treated as non-threaded —
   only 'reply' marker triggers thread resolution.
Codex 6/10 found seen_ids.insert() still ran before filters_match()
in the historical delivery path — same bug that was already fixed in
the NIP-50 search path. Moved dedup after per-filter acceptance so
an event that fails filter A remains eligible for filter B.
Adds the four tests codex asked for to close the coverage gap:

1. test_nip10_thread_reply_not_in_top_level — WS reply hidden from
   top-level messages query (proves thread_metadata created)
2. test_nip17_gift_wrap_not_searchable — kind:1059 not in REST search
   results (proves dispatch_persistent_event skips indexing)
3. test_nip50_search_relevance_order — exact-match message present in
   results (proves relevance ordering, not chronological)
4. test_historical_req_dedup_preserves_or_semantics — event returned
   via filter B even when filter A's query fetched it first (proves
   dedup-after-acceptance)

All 14 interop E2E tests pass. 92/92 total E2E pass.
…ance asserts ordering

Codex 7/10 found two test issues:
1. gift_wrap_not_searchable passed for wrong reason (REST search drops
   channel_id=None anyway). Now sends BOTH a kind:1059 gift wrap AND a
   kind:9 control message with the same unique content, searches via
   NIP-50, asserts kind:9 IS found but kind:1059 is NOT.
2. relevance_order only checked presence, not ordering. Now asserts the
   exact-match message is the FIRST result.
Codex 8/10 correctly identified that the previous test proved 'not
returned by channel-scoped NIP-50' rather than 'not indexed'. NIP-50
filters by channel_id, so kind:1059 (channel_id=None) would never
appear regardless of indexing.

Now queries Typesense /multi_search directly — bypasses all relay-level
filtering. Asserts kind:9 control IS indexed, kind:1059 is NOT.
Codex found that filter_to_query_params encodes kinds:[] as Some(vec![])
meaning 'match nothing', but query_events dropped empty vecs via
.filter(|k| !k.is_empty()), making it match ALL kinds instead.
Added early return for empty kinds before hitting SQL.
Proves empty-kinds sentinel is honored end-to-end: REQ with kinds:[]
returns zero historical events and EOSE, even when the channel has data.
Codex 8/10 found that filter_to_query_params used the subscription-level
channel_id (None for multi-channel subs) instead of extracting per-filter
#h. Unrelated accessible-channel rows could consume the LIMIT before the
requested channel's events were fetched.

Now extracts per-filter #h channel and passes it to filter_to_query_params,
same pattern as the NIP-50 search path.
@tlongwell-block tlongwell-block merged commit 39e856f into main Mar 16, 2026
8 checks passed
@tlongwell-block tlongwell-block deleted the nostr-interop-nip50-nip10-nip17 branch March 16, 2026 15:15
tlongwell-block added a commit that referenced this pull request Mar 16, 2026
* origin/main:
  feat: agent users:write scope + system messages in chat (#73)
  chore(deps): update swatinem/rust-cache digest to e18b497 (#71)
  chore(deps): update actions/upload-artifact digest to ea165f8 (#70)
  feat: NIP-50 search, NIP-10 threads, NIP-17 DMs, Sprout DM discovery (#74)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant