feat: NIP-50 search, NIP-10 threads, NIP-17 DMs, Sprout DM discovery#74
Merged
tlongwell-block merged 23 commits intomainfrom Mar 16, 2026
Merged
feat: NIP-50 search, NIP-10 threads, NIP-17 DMs, Sprout DM discovery#74tlongwell-block merged 23 commits intomainfrom
tlongwell-block merged 23 commits intomainfrom
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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)
filters_matchbug where persistent search subs match all future events)authors,since,until,kinds, and per-filter#hpushed into Typesensefilter_by#hvalues intersected with accessible channels; invalid/inaccessible#hmatches nothing (not all)limit == 0handled explicitly#pgating so wildcard-kind searches aren't spuriously blockedFiles:
handlers/req.rs,sprout-db/event.rs(batch fetch),sprout-db/lib.rsNIP-10 Threads (pre-storage ancestry resolution)
etags for NIP-10root/replymarkers before storageinsert_event_with_thread_metadata— prevents documented race conditionetags treated as non-threaded context (not replies)["e",<root>,"","reply"]) when parent has nothread_metadataFiles:
handlers/event.rsNIP-17 DMs (opaque gift-wrap storage)
channel_id = None— clienthtags ignored (prevents channel-scoped bypass of#pgating)P_GATED_KINDSconsolidates#penforcement for kind:44100, 44101, and 1059Files:
handlers/event.rs,handlers/req.rsSprout DM Discovery
open_dm_handlerandadd_dm_member_handleremit NIP-29 discovery events on new DM channelshiddentag for DMs (client hint, not security boundary)Files:
api/dms.rs,handlers/side_effects.rs2. 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 thefilter_byexceeds this. Affects both NIP-50 and the existing REST/api/search.Files:
sprout-search/query.rsHistorical REQ hardening
#hpushdown — extracts channel from each filter's#htag instead of using the subscription-level channel_id (prevents under-fetch when filters target different channels)#ppushdown viaevent_mentionsJOIN — gift-wrap/membership queries no longer miss results after 500-row LIMITpubkeypushed into WHERE clause for single-author filterskinds:[]returns zero rows — empty-kinds sentinel now honored by DB layer (was matching all kinds)Files:
handlers/req.rs,sprout-db/event.rs3. Docs and tests
[1, 10, 11, 17, 25, 29, 42, 50]kinds:[]returns zero eventsmax_subscriptionsassertion (1024), search indexing delay (2s)Testing
e2e_nostr_interop(new)e2e_relaye2e_rest_apie2e_mcpLive testing
echo: nak-e2e-...)What we're NOT doing