feat: NIP-29 native compatibility — standard nostr clients can chat on Sprout - #63
Merged
Conversation
* origin/main: feat: optional UDS listener + health endpoints for Kubernetes deployment (#62) # Conflicts: # crates/sprout-relay/src/config.rs
- Collapse nested if in auth.rs allowlist check (clippy::collapsible_if) - Fix biome import formatting in relayClient.ts - Fix biome array formatting in e2eBridge.ts
- Replace separate soft_delete + insert with atomic replace_addressable_event() that wraps both in a single transaction, preventing duplicate addressable events under concurrent membership changes - Extract emit_addressable_discovery_event() helper to eliminate 3x code duplication in emit_group_discovery_events() - Add soft_delete_discovery_events() for bulk cleanup of 39000/39001/39002 - Clean up discovery events in handle_delete_group so deleted groups stop appearing in group discovery queries - Remove dead add_to_allowlist() (zero callers) and soft_delete_previous_addressable() (replaced by transactional method)
…gration BINARY(32) enforces exact 32-byte length at the DB level, preventing malformed rows. VARBINARY(32) only sets a max, not a minimum.
One-time migration ensures historical messages stored as kind:40001 appear in kind:9 subscriptions. The desktop client's legacy 40001 entry in CHANNEL_EVENT_KINDS becomes unnecessary once this runs.
…stem message errors - Remove redundant kind_i32 parameter from replace_addressable_event() — derive kind solely from the event to eliminate dual-derivation fragility - Replace silent let _ = on system message insert with logged warning, consistent with error handling in the rest of the file
…ro-row window Reverse the operation order: insert the new event first, then soft-delete previous active events (excluding the just-inserted row by ID). This guarantees at least one active addressable event at all times — the previous delete-then-insert ordering could leave zero active rows if INSERT IGNORE no-oped on a duplicate event ID.
Replace unwrap_or(false) with explicit match that logs the DB error with connection and pubkey context. Fail-closed behavior preserved, but infra failures are now distinguishable from 'pubkey not in allowlist' in relay logs.
Expand test_nip29_standard_client_flow to query and assert that group admins (39001) and group members (39002) discovery events are emitted alongside the existing 39000 metadata check.
…FOR UPDATE Add an exclusive row lock (SELECT ... FOR UPDATE) at the start of the replace_addressable_event transaction. This serializes concurrent writers for the same logical address (kind, pubkey, channel_id) — the second transaction blocks until the first commits, preventing duplicate active rows. Combined with insert-first ordering, this guarantees exactly one active addressable event per group at all times.
…nt denial Replace unwrap_or(false) on get_channel() with explicit match that logs the DB error with connection and channel context. Fail-closed behavior preserved, but infra failures are now distinguishable from 'channel is not open' in relay logs.
Add idx_events_addressable on (kind, pubkey, channel_id, deleted_at) so that SELECT ... FOR UPDATE in replace_addressable_event() can take a next-key lock even on a cold address (no existing rows). Without this index, InnoDB has no key range to lock, allowing two concurrent first-time emissions to both insert — violating the single-active-row invariant. Update the code comment to document the index dependency.
JavaScript's numeric separator (4_0001 = 40001) was invisible to our rg '40001' search. Optimistic reply events had kind:40001 instead of kind:9, causing isTimelineContentEvent to filter them out — replies never appeared in the timeline. Use KIND_STREAM_MESSAGE constant.
tlongwell-block
added a commit
that referenced
this pull request
Mar 14, 2026
…-heartbeat * origin/main: feat: NIP-29 native compatibility — standard nostr clients can chat on Sprout (#63)
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.
What
Makes Sprout compatible with standard NIP-29 nostr clients (Chachi, 0xchat, any NDK-based app) for basic group chat. The Sprout desktop client and MCP/ACP agent harness retain all current functionality — nothing removed or degraded.
The change in one sentence
KIND_STREAM_MESSAGEmoves from40001to9(NIP-29 group chat), three small features are added, and ~120 test literals are updated.Why
Internal users want to use open-source nostr clients for basic chat on Sprout relays. Until now, Sprout used a custom kind (40001) that no standard client understands. NIP-29 (relay-based groups) defines kind:9 as the standard group chat message — widely adopted by NDK, Chachi, and 0xchat.
Changes
WI-1: Kind constant change
KIND_STREAM_MESSAGE: 40001 → 9inkind.rs. The ~62 Rust references auto-propagate at compile time. Hardcoded literals fixed manually across desktop (relayClient.ts,kinds.ts,hooks.ts) and MCP (server.rs). Note:hooks.tsused JavaScript's numeric separator (4_0001=40001), which was invisible torg '40001'— caught via desktop e2e test failure.WI-2: NIP-29 group discovery emission
New
emit_group_discovery_events()inside_effects.rs. Emits relay-signed kind:39000 (group metadata), kind:39001 (group admins), and kind:39002 (group members) events whenever channels are created, updated, or membership changes. Called from both WebSocket NIP-29 handlers and the REST API channel creation endpoint.channel_id = Some(...)) so existing access control applies — private channel member lists are only visible to membersreplace_addressable_event()wraps insert + soft-delete in a single DB transaction withSELECT ... FOR UPDATEserializationidx_events_addressableon(kind, pubkey, channel_id, deleted_at)enables InnoDB next-key gap locking even on cold addresses (no existing rows)soft_delete_discovery_events()emit_addressable_discovery_event()helperWI-3: Pubkey allowlist auth
New
pubkey_allowlisttable (BINARY(32)for fixed-size cryptographic keys) +SPROUT_PUBKEY_ALLOWLISTenv var. When enabled, NIP-42 pubkey-only connections (no JWT/API token) are restricted to allowlisted pubkeys. Users with valid API tokens or Okta JWTs bypass the allowlist entirely. Fail-closed on DB errors with explicit warning logs for observability.WI-4: Desktop transition
Legacy kind:40001 added to
CHANNEL_EVENT_KINDSso the desktop sees pre-migration messages during any transition period.WI-5: Literal updates + data migration
~120 hardcoded
40001references updated to9across test files, comments, TOML config strings, and documentation. SQL migration (20260318000002) converts existing kind:40001 rows to kind:9.WI-6: Integration test
New
test_nip29_standard_client_flow— end-to-end test exercising: group discovery query (39000 + 39001 + 39002), kind:9 send/receive, kind:7 reaction, kind:5 deletion, and h-tag enforcement.What standard clients get
What doesn't change
Testing
Crossfire Review
Issues fixed across iterations
replace_addressable_event()with insert-first orderingSELECT ... FOR UPDATEwith backing composite index for gap lockingsoft_delete_discovery_events()inhandle_delete_groupadd_to_allowlist()emit_addressable_discovery_event()helperBINARY(32)for fixed-size keys4_0001inhooks.ts(JS numeric separator = 40001) broke optimistic reply renderingDeferred to Phase 2
replace_addressable_eventStats