From 9166e07286249daa6012725ad6f8071b9799bbc1 Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Sun, 5 Apr 2026 22:13:52 -0400 Subject: [PATCH] feat: NIP-23 long-form articles (kind:30023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept kind:30023 in the per-kind scope allowlist (MessagesWrite), add NIP-23 to the NIP-11 supported_nips, and extract is_global_only_kind() to ensure stray h-tags are ignored for global-only kinds. kind:30023 events are parameterized replaceable (NIP-33, 30000-39999 range) — replacement keyed by (pubkey, kind, d_tag) is handled by PR 2's existing replace_parameterized_event() path. Events are stored globally (channel_id = NULL), not channel-scoped. Changes: - kind.rs: KIND_LONG_FORM constant (30023) + ALL_KINDS registration - ingest.rs: scope allowlist + is_global_only_kind() helper + 7 unit tests - nip11.rs: SUPPORTED_NIPS constant with NIP-23 + 2 unit tests - e2e_long_form.rs: 5 E2E tests (acceptance, retrieval, stray h-tag, NIP-33 replacement, stale-write protection) Known limitation (documented): stray h-tags on global-only kinds remain on stored events (Nostr signature integrity) and are matchable via #h queries on the read path. This is pre-existing (affects kind:0, 1, 3) and tracked as a follow-up filter-layer fix. --- crates/sprout-core/src/kind.rs | 5 + crates/sprout-relay/src/handlers/ingest.rs | 86 ++++- crates/sprout-relay/src/nip11.rs | 37 +- .../sprout-test-client/tests/e2e_long_form.rs | 319 ++++++++++++++++++ 4 files changed, 432 insertions(+), 15 deletions(-) create mode 100644 crates/sprout-test-client/tests/e2e_long_form.rs diff --git a/crates/sprout-core/src/kind.rs b/crates/sprout-core/src/kind.rs index 7b62432a4..164b48ae2 100644 --- a/crates/sprout-core/src/kind.rs +++ b/crates/sprout-core/src/kind.rs @@ -21,6 +21,10 @@ pub const KIND_REACTION: u32 = 7; pub const KIND_GIFT_WRAP: u32 = 1059; /// NIP-94: File metadata attachment. pub const KIND_FILE_METADATA: u32 = 1063; +/// NIP-23: Long-form content (articles, blog posts, RFCs). +/// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`. +/// Stored globally (channel_id = NULL); author-owned, not channel-scoped. +pub const KIND_LONG_FORM: u32 = 30023; /// NIP-42 auth event — never stored (carries bearer tokens). pub const KIND_AUTH: u32 = 22242; @@ -276,6 +280,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_SUBSCRIPTION_RESUMED, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_LONG_FORM, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_FORUM_COMMENT, diff --git a/crates/sprout-relay/src/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs index 29329618c..6b00a7982 100644 --- a/crates/sprout-relay/src/handlers/ingest.rs +++ b/crates/sprout-relay/src/handlers/ingest.rs @@ -14,13 +14,13 @@ use sprout_auth::Scope; use sprout_core::kind::{ event_kind_u32, is_parameterized_replaceable, KIND_AUTH, KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, - KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_CREATE_GROUP, - KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, - KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, - KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_STREAM_MESSAGE, - KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, - KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, - KIND_STREAM_REMINDER, KIND_TEXT_NOTE, + KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, + KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, + KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, + KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, + KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, + KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE, }; use sprout_core::verification::verify_event; @@ -142,7 +142,7 @@ pub enum IngestError { fn required_scope_for_kind(kind: u32, event: &Event) -> Result { match kind { KIND_PROFILE => Ok(Scope::UsersWrite), - KIND_TEXT_NOTE => Ok(Scope::MessagesWrite), + KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite), KIND_CONTACT_LIST => Ok(Scope::UsersWrite), KIND_DELETION | KIND_REACTION @@ -242,6 +242,24 @@ pub(crate) async fn derive_reaction_channel( } } +/// Kinds that are always global (`channel_id = NULL`). +/// +/// If a client includes a stray `h` tag on these kinds, the ingest pipeline +/// sets `channel_id = None` — these events are never channel-scoped. +/// +/// Note: the raw `h` tag remains on the stored event (Nostr events are signed, +/// so tags cannot be stripped without invalidating the signature). The read-path +/// filter matching in `filter.rs` treats explicit `h` tags as authoritative, +/// which means a stray `h` tag can still match `#h` queries. This is a known +/// limitation affecting all global-only kinds and should be addressed in the +/// filter layer as a follow-up. +pub(crate) fn is_global_only_kind(kind: u32) -> bool { + matches!( + kind, + KIND_PROFILE | KIND_TEXT_NOTE | KIND_CONTACT_LIST | KIND_LONG_FORM + ) +} + /// Kinds that require an `h` tag for channel scoping. pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { matches!( @@ -844,9 +862,7 @@ pub async fn ingest_event( }; // ── 5b. Global-only kinds ignore h-tags ───────────────────────────── - // kind:0 (profile), kind:1 (text note), kind:3 (contact list) are always global. - // If a client includes a stray h-tag, ignore it — these kinds are never channel-scoped. - if matches!(kind_u32, KIND_PROFILE | KIND_TEXT_NOTE | KIND_CONTACT_LIST) { + if is_global_only_kind(kind_u32) { channel_id = None; } @@ -1314,8 +1330,8 @@ pub async fn ingest_event( mod tests { use super::*; use sprout_core::kind::{ - KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, + KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_LONG_FORM, + KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, }; #[test] @@ -1371,13 +1387,54 @@ mod tests { assert!(!requires_h_channel_scope(KIND_REACTION)); } + #[test] + fn long_form_is_in_scope_allowlist() { + let dummy = make_dummy_event(); + assert!( + required_scope_for_kind(KIND_LONG_FORM, &dummy).is_ok(), + "KIND_LONG_FORM (30023) should be accepted" + ); + } + + #[test] + fn long_form_requires_messages_write_scope() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_LONG_FORM, &dummy).unwrap(), + Scope::MessagesWrite, + ); + } + + #[test] + fn long_form_does_not_require_h_tag() { + // kind:30023 is global (author-owned, not channel-scoped) + assert!(!requires_h_channel_scope(KIND_LONG_FORM)); + } + + #[test] + fn long_form_is_global_only() { + // kind:30023 is always global — ingest nulls channel_id even if an h-tag is present + assert!(is_global_only_kind(KIND_LONG_FORM)); + } + + #[test] + fn global_only_and_channel_scoped_are_disjoint() { + // A kind cannot be both global-only and channel-scoped + for kind in 0..=65535u32 { + assert!( + !(is_global_only_kind(kind) && requires_h_channel_scope(kind)), + "kind {kind} is both global-only and channel-scoped" + ); + } + } + #[test] fn ephemeral_kinds_not_in_scope_allowlist() { assert!(required_scope_for_kind(KIND_PRESENCE_UPDATE, &make_dummy_event()).is_err()); } #[test] - fn per_kind_scope_allowlist_covers_all_18_migrated_kinds() { + fn per_kind_scope_allowlist_covers_all_migrated_kinds() { let dummy = make_dummy_event(); let migrated = [ KIND_PROFILE, @@ -1398,6 +1455,7 @@ mod tests { KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_FORUM_COMMENT, + KIND_LONG_FORM, ]; for kind in migrated { assert!( diff --git a/crates/sprout-relay/src/nip11.rs b/crates/sprout-relay/src/nip11.rs index 1bd8cb9b9..c9cf2e62e 100644 --- a/crates/sprout-relay/src/nip11.rs +++ b/crates/sprout-relay/src/nip11.rs @@ -4,6 +4,11 @@ use serde::{Deserialize, Serialize}; use crate::connection::MAX_FRAME_BYTES; +/// NIPs supported by this relay, advertised in the NIP-11 document. +/// Kept as a module-level constant so tests can verify it without constructing +/// a full `Config` (which reads env vars and races with config.rs tests). +pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 42, 50]; + /// Relay information document served at `GET /` with `Accept: application/nostr+json`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayInfo { @@ -56,7 +61,7 @@ impl RelayInfo { description: "Sprout — private team communication relay".to_string(), pubkey: None, contact: None, - supported_nips: vec![1, 2, 10, 11, 16, 17, 25, 29, 33, 42, 50], + supported_nips: SUPPORTED_NIPS.to_vec(), software: "https://github.com/sprout-rs/sprout".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), limitation: Some(RelayLimitation { @@ -80,3 +85,33 @@ pub async fn relay_info_handler( ) -> axum::response::Json { axum::response::Json(RelayInfo::from_config(&state.config)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn supported_nips_includes_nip23_and_nip33() { + // Tests the production SUPPORTED_NIPS constant directly — no Config::from_env() + // needed, avoiding the env-var race with config.rs tests. + assert!( + SUPPORTED_NIPS.contains(&23), + "NIP-23 (long-form content) must be advertised" + ); + assert!( + SUPPORTED_NIPS.contains(&33), + "NIP-33 (parameterized replaceable) must be advertised" + ); + } + + #[test] + fn supported_nips_are_sorted() { + let mut sorted = SUPPORTED_NIPS.to_vec(); + sorted.sort(); + assert_eq!( + SUPPORTED_NIPS, + &sorted[..], + "supported_nips should be sorted" + ); + } +} diff --git a/crates/sprout-test-client/tests/e2e_long_form.rs b/crates/sprout-test-client/tests/e2e_long_form.rs new file mode 100644 index 000000000..ff2c4934b --- /dev/null +++ b/crates/sprout-test-client/tests/e2e_long_form.rs @@ -0,0 +1,319 @@ +//! End-to-end tests for NIP-23 long-form content (kind:30023). +//! +//! These tests require a running relay instance. By default they are marked +//! `#[ignore]` so that `cargo test` does not fail in CI when the relay is not +//! available. +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! cargo test --test e2e_long_form -- --ignored +//! ``` +//! +//! Override the relay URL with the `RELAY_URL` environment variable: +//! +//! ```text +//! RELAY_URL=ws://relay.example.com cargo test --test e2e_long_form -- --ignored +//! ``` + +use std::time::Duration; + +use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp}; +use sprout_test_client::SproutTestClient; + +const KIND_LONG_FORM: u16 = 30023; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn sub_id(name: &str) -> String { + format!("e2e-{name}-{}", uuid::Uuid::new_v4()) +} + +/// Build a kind:30023 event with standard NIP-23 tags. +fn build_long_form_event( + keys: &Keys, + d_tag: &str, + title: &str, + content: &str, + extra_tags: Vec, +) -> nostr::Event { + let mut tags = vec![ + Tag::parse(&["d", d_tag]).unwrap(), + Tag::parse(&["title", title]).unwrap(), + ]; + tags.extend(extra_tags); + EventBuilder::new(Kind::Custom(KIND_LONG_FORM), content, tags) + .sign_with_keys(keys) + .unwrap() +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +/// kind:30023 events are accepted by the relay. +#[tokio::test] +#[ignore] +async fn test_long_form_accepted() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let event = build_long_form_event( + &keys, + "test-article-accept", + "Test Article", + "# Hello\n\nThis is a test article.", + vec![], + ); + + let ok = client.send_event(event).await.expect("send event"); + assert!( + ok.accepted, + "relay should accept kind:30023: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} + +/// kind:30023 events are retrievable via REQ with kinds filter. +#[tokio::test] +#[ignore] +async fn test_long_form_retrievable() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let d_tag = format!("retrieve-{}", uuid::Uuid::new_v4().simple()); + let event = build_long_form_event( + &keys, + &d_tag, + "Retrievable Article", + "# Retrievable\n\nBody text.", + vec![], + ); + let event_id = event.id; + + let ok = client.send_event(event).await.expect("send event"); + assert!(ok.accepted, "relay should accept: {}", ok.message); + + // Query back by kind + author + let sid = sub_id("retrieve"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_LONG_FORM)) + .author(keys.public_key()); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + events.iter().any(|e| e.id == event_id), + "should find the published article in query results" + ); + + client.disconnect().await.expect("disconnect"); +} + +/// kind:30023 is stored globally (channel_id = NULL) — stray h-tags are ignored. +/// An event with a stray h-tag should still be retrievable via a global query +/// (no h-tag filter), proving it was stored as global. +#[tokio::test] +#[ignore] +async fn test_long_form_stray_h_tag_ignored() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + // Publish with a stray h-tag (a UUID that doesn't correspond to any channel). + let fake_channel = uuid::Uuid::new_v4().to_string(); + let d_tag = format!("stray-h-{}", uuid::Uuid::new_v4().simple()); + let event = build_long_form_event( + &keys, + &d_tag, + "Stray H-Tag Article", + "Should be stored globally despite h-tag.", + vec![Tag::parse(&["h", &fake_channel]).unwrap()], + ); + let event_id = event.id; + + let ok = client.send_event(event).await.expect("send event"); + assert!(ok.accepted, "relay should accept: {}", ok.message); + + // Query globally (no h-tag filter) — should find the article. + let sid = sub_id("stray-h"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_LONG_FORM)) + .author(keys.public_key()); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + events.iter().any(|e| e.id == event_id), + "article with stray h-tag should be retrievable via global query" + ); + + // NOTE: Ideally, querying with #h= should NOT return the + // article since it's global. However, the raw h-tag remains on the stored + // event (Nostr events are signed — tags can't be stripped without breaking + // the signature), and the read-path filter matching in filter.rs treats + // explicit h-tags as authoritative. This is a pre-existing limitation + // affecting all global-only kinds (0, 1, 3, 30023) and should be fixed + // in the filter layer as a follow-up. + + client.disconnect().await.expect("disconnect"); +} + +/// NIP-33 replacement: publishing a newer kind:30023 with the same d-tag replaces the old one. +#[tokio::test] +#[ignore] +async fn test_long_form_nip33_replacement() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let d_tag = format!("replace-{}", uuid::Uuid::new_v4().simple()); + + // Publish v1 + let v1 = build_long_form_event(&keys, &d_tag, "Article v1", "Version 1 content.", vec![]); + let ok1 = client.send_event(v1).await.expect("send v1"); + assert!(ok1.accepted, "v1 should be accepted: {}", ok1.message); + + // Small delay to ensure different created_at timestamps + tokio::time::sleep(Duration::from_secs(1)).await; + + // Publish v2 with the same d-tag + let v2 = build_long_form_event( + &keys, + &d_tag, + "Article v2", + "Version 2 content — updated.", + vec![], + ); + let v2_id = v2.id; + let ok2 = client.send_event(v2).await.expect("send v2"); + assert!(ok2.accepted, "v2 should be accepted: {}", ok2.message); + + // Query — should only get v2 (v1 replaced) + let sid = sub_id("replace"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_LONG_FORM)) + .author(keys.public_key()) + .custom_tag(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert_eq!( + events.len(), + 1, + "should have exactly one event after replacement" + ); + assert_eq!(events[0].id, v2_id, "surviving event should be v2"); + assert!( + events[0].content.contains("Version 2"), + "content should be v2" + ); + + client.disconnect().await.expect("disconnect"); +} + +/// NIP-33 stale-write protection: an older event cannot replace a newer one. +#[tokio::test] +#[ignore] +async fn test_long_form_stale_write_rejected() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let d_tag = format!("stale-{}", uuid::Uuid::new_v4().simple()); + + // Publish the "newer" event first (with a future-ish timestamp) + let newer = { + let tags = vec![ + Tag::parse(&["d", &d_tag]).unwrap(), + Tag::parse(&["title", "Newer Article"]).unwrap(), + ]; + EventBuilder::new(Kind::Custom(KIND_LONG_FORM), "Newer content.", tags) + .custom_created_at(Timestamp::from(nostr::Timestamp::now().as_u64() + 100)) + .sign_with_keys(&keys) + .unwrap() + }; + let newer_id = newer.id; + let ok1 = client.send_event(newer).await.expect("send newer"); + assert!(ok1.accepted, "newer should be accepted: {}", ok1.message); + + // Now try to publish an "older" event with the same d-tag but earlier timestamp + let older = { + let tags = vec![ + Tag::parse(&["d", &d_tag]).unwrap(), + Tag::parse(&["title", "Older Article"]).unwrap(), + ]; + EventBuilder::new(Kind::Custom(KIND_LONG_FORM), "Older content.", tags) + .custom_created_at(Timestamp::from(nostr::Timestamp::now().as_u64() - 100)) + .sign_with_keys(&keys) + .unwrap() + }; + let _ok2 = client.send_event(older).await.expect("send older"); + // Stale write may be rejected or accepted-as-duplicate — either way, + // the older event must NOT replace the newer one. + + // Query — should still have the newer event + let sid = sub_id("stale"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_LONG_FORM)) + .author(keys.public_key()) + .custom_tag(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert_eq!(events.len(), 1, "should have exactly one event"); + assert_eq!( + events[0].id, newer_id, + "surviving event should be the newer one" + ); + assert!( + events[0].content.contains("Newer"), + "content should be from the newer event" + ); + + client.disconnect().await.expect("disconnect"); +}