diff --git a/crates/sprout-core/src/kind.rs b/crates/sprout-core/src/kind.rs index 65f29670a..72591e074 100644 --- a/crates/sprout-core/src/kind.rs +++ b/crates/sprout-core/src/kind.rs @@ -25,6 +25,10 @@ pub const KIND_FILE_METADATA: u32 = 1063; /// 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-38: User status (general, music, or custom d-tag). +/// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`. +/// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped. +pub const KIND_USER_STATUS: u32 = 30315; /// NIP-78 / NIP-RS: Per-client read state blob for cross-device read position sync. /// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`. /// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped. @@ -316,6 +320,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_LONG_FORM, + KIND_USER_STATUS, KIND_READ_STATE, KIND_FORUM_POST, KIND_FORUM_VOTE, diff --git a/crates/sprout-relay/src/api/events.rs b/crates/sprout-relay/src/api/events.rs index e59917986..76d5eae2f 100644 --- a/crates/sprout-relay/src/api/events.rs +++ b/crates/sprout-relay/src/api/events.rs @@ -22,12 +22,16 @@ use super::{ use sprout_core::kind::{ event_kind_u32, KIND_CONTACT_LIST, KIND_LONG_FORM, KIND_PROFILE, KIND_READ_STATE, - KIND_TEXT_NOTE, + KIND_TEXT_NOTE, KIND_USER_STATUS, }; /// Global event kinds that require `UsersRead` scope. -pub(crate) const GLOBAL_USER_DATA_KINDS: [u32; 3] = - [KIND_PROFILE, KIND_CONTACT_LIST, KIND_READ_STATE]; +pub(crate) const GLOBAL_USER_DATA_KINDS: [u32; 4] = [ + KIND_PROFILE, + KIND_CONTACT_LIST, + KIND_READ_STATE, + KIND_USER_STATUS, +]; /// Global event kinds that require `MessagesRead` scope. pub(crate) const GLOBAL_MESSAGE_KINDS: [u32; 2] = [KIND_TEXT_NOTE, KIND_LONG_FORM]; @@ -151,7 +155,9 @@ pub async fn submit_event( #[cfg(test)] mod tests { - use sprout_core::kind::{KIND_CONTACT_LIST, KIND_LONG_FORM, KIND_PROFILE, KIND_TEXT_NOTE}; + use sprout_core::kind::{ + KIND_CONTACT_LIST, KIND_LONG_FORM, KIND_PROFILE, KIND_TEXT_NOTE, KIND_USER_STATUS, + }; use super::{GLOBAL_MESSAGE_KINDS, GLOBAL_USER_DATA_KINDS}; @@ -201,6 +207,14 @@ mod tests { )); } + #[test] + fn kind30315_user_status_allowed_with_users_read() { + assert!(scope_check_for_global_event( + KIND_USER_STATUS, + &[sprout_auth::Scope::UsersRead], + )); + } + // ── Negative cases: wrong scope is denied ──────────────────────────── #[test] @@ -235,6 +249,14 @@ mod tests { )); } + #[test] + fn kind30315_user_status_denied_with_only_messages_read() { + assert!(!scope_check_for_global_event( + KIND_USER_STATUS, + &[sprout_auth::Scope::MessagesRead], + )); + } + // ── Closed-default: unknown kinds are always denied ────────────────── #[test] diff --git a/crates/sprout-relay/src/handlers/event.rs b/crates/sprout-relay/src/handlers/event.rs index 741763e0f..10bc93225 100644 --- a/crates/sprout-relay/src/handlers/event.rs +++ b/crates/sprout-relay/src/handlers/event.rs @@ -32,7 +32,7 @@ fn bounded_kind_label(kind: u32) -> String { 0..=9 | 1059 | 1063 => kind.to_string(), 9000..=9022 | 9100 | 9110 | 9900 => kind.to_string(), 20000..=29999 => kind.to_string(), - 30023 | 39000..=39003 => kind.to_string(), + 30023 | 30315 | 39000..=39003 => kind.to_string(), 40002..=40100 => kind.to_string(), 41001..=41003 => kind.to_string(), 42001..=42003 => kind.to_string(), diff --git a/crates/sprout-relay/src/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs index b0fdc7c3e..490cbeb67 100644 --- a/crates/sprout-relay/src/handlers/ingest.rs +++ b/crates/sprout-relay/src/handlers/ingest.rs @@ -25,7 +25,7 @@ use sprout_core::kind::{ KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, 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_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_USER_STATUS, }; use sprout_core::verification::verify_event; @@ -154,7 +154,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::UsersWrite), KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite), - KIND_CONTACT_LIST | KIND_READ_STATE => Ok(Scope::UsersWrite), + KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS => Ok(Scope::UsersWrite), KIND_DELETION | KIND_REACTION | KIND_GIFT_WRAP @@ -289,6 +289,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_TEXT_NOTE | KIND_CONTACT_LIST | KIND_LONG_FORM + | KIND_USER_STATUS | KIND_READ_STATE // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). @@ -1393,7 +1394,7 @@ mod tests { use super::*; use sprout_core::kind::{ KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_LONG_FORM, - KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, + KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, KIND_USER_STATUS, }; #[test] @@ -1479,6 +1480,25 @@ mod tests { assert!(is_global_only_kind(KIND_LONG_FORM)); } + #[test] + fn user_status_requires_users_write_scope() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_USER_STATUS, &dummy).unwrap(), + Scope::UsersWrite, + ); + } + + #[test] + fn user_status_is_global_only() { + assert!(is_global_only_kind(KIND_USER_STATUS)); + } + + #[test] + fn user_status_does_not_require_h_tag() { + assert!(!requires_h_channel_scope(KIND_USER_STATUS)); + } + #[test] fn global_only_and_channel_scoped_are_disjoint() { // A kind cannot be both global-only and channel-scoped @@ -1518,6 +1538,7 @@ mod tests { KIND_FORUM_VOTE, KIND_FORUM_COMMENT, KIND_LONG_FORM, + KIND_USER_STATUS, ]; for kind in migrated { assert!( diff --git a/crates/sprout-relay/src/nip11.rs b/crates/sprout-relay/src/nip11.rs index c9cf2e62e..61657616f 100644 --- a/crates/sprout-relay/src/nip11.rs +++ b/crates/sprout-relay/src/nip11.rs @@ -7,7 +7,7 @@ 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]; +pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 38, 42, 50]; /// Relay information document served at `GET /` with `Accept: application/nostr+json`. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -104,6 +104,14 @@ mod tests { ); } + #[test] + fn supported_nips_includes_nip38() { + assert!( + SUPPORTED_NIPS.contains(&38), + "NIP-38 (user statuses) must be advertised" + ); + } + #[test] fn supported_nips_are_sorted() { let mut sorted = SUPPORTED_NIPS.to_vec(); diff --git a/crates/sprout-test-client/tests/e2e_user_status.rs b/crates/sprout-test-client/tests/e2e_user_status.rs new file mode 100644 index 000000000..2a68f60e0 --- /dev/null +++ b/crates/sprout-test-client/tests/e2e_user_status.rs @@ -0,0 +1,282 @@ +//! End-to-end tests for NIP-38 user statuses (kind:30315). +//! +//! 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_user_status -- --ignored +//! ``` +//! +//! Override the relay URL with the `RELAY_URL` environment variable: +//! +//! ```text +//! RELAY_URL=ws://relay.example.com cargo test --test e2e_user_status -- --ignored +//! ``` + +use std::time::Duration; + +use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp}; +use sprout_test_client::SproutTestClient; + +const KIND_USER_STATUS: u16 = 30315; + +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:30315 event with a d-tag and content. +fn build_user_status_event( + keys: &Keys, + d_tag: &str, + content: &str, + extra_tags: Vec, +) -> nostr::Event { + let mut tags = vec![Tag::parse(&["d", d_tag]).unwrap()]; + tags.extend(extra_tags); + EventBuilder::new(Kind::Custom(KIND_USER_STATUS), content, tags) + .sign_with_keys(keys) + .unwrap() +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +/// kind:30315 events are accepted by the relay. +#[tokio::test] +#[ignore] +async fn test_user_status_accepted() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let event = build_user_status_event(&keys, "general", "Working on NIP-38 support", vec![]); + + let ok = client.send_event(event).await.expect("send event"); + assert!( + ok.accepted, + "relay should accept kind:30315: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} + +/// kind:30315 events are retrievable via REQ with kinds filter. +#[tokio::test] +#[ignore] +async fn test_user_status_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_user_status_event(&keys, &d_tag, "Currently online", 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_USER_STATUS)) + .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 user status in query results" + ); + + client.disconnect().await.expect("disconnect"); +} + +/// NIP-33 replacement: publishing a newer kind:30315 with the same d-tag replaces the old one. +#[tokio::test] +#[ignore] +async fn test_user_status_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_user_status_event(&keys, &d_tag, "Status v1", 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_user_status_event(&keys, &d_tag, "Status v2 — 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_USER_STATUS)) + .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("v2"), "content should be v2"); + + client.disconnect().await.expect("disconnect"); +} + +/// NIP-38: multiple d-tags coexist — "general" and "music" are independent status slots. +#[tokio::test] +#[ignore] +async fn test_user_status_multiple_d_tags_coexist() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let general_d = format!("general-{}", uuid::Uuid::new_v4().simple()); + let music_d = format!("music-{}", uuid::Uuid::new_v4().simple()); + + // Publish general status + let general = build_user_status_event(&keys, &general_d, "Working on code", vec![]); + let general_id = general.id; + let ok1 = client.send_event(general).await.expect("send general"); + assert!(ok1.accepted, "general should be accepted: {}", ok1.message); + + // Publish music status + let music = build_user_status_event(&keys, &music_d, "Listening to jazz", vec![]); + let music_id = music.id; + let ok2 = client.send_event(music).await.expect("send music"); + assert!(ok2.accepted, "music should be accepted: {}", ok2.message); + + // Query by kind + author — both should be returned + let sid = sub_id("multi-dtag"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_USER_STATUS)) + .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 == general_id), + "general status should be present" + ); + assert!( + events.iter().any(|e| e.id == music_id), + "music status should be present" + ); + + client.disconnect().await.expect("disconnect"); +} + +/// NIP-33 stale-write protection: an older event cannot replace a newer one. +#[tokio::test] +#[ignore] +async fn test_user_status_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()]; + EventBuilder::new(Kind::Custom(KIND_USER_STATUS), "Newer status", 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()]; + EventBuilder::new(Kind::Custom(KIND_USER_STATUS), "Older status", 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_USER_STATUS)) + .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"); +}