diff --git a/crates/sprout-cli/src/commands/social.rs b/crates/sprout-cli/src/commands/social.rs index 97448cfd7..a3bd66d4e 100644 --- a/crates/sprout-cli/src/commands/social.rs +++ b/crates/sprout-cli/src/commands/social.rs @@ -1,4 +1,9 @@ +use nostr::{EventBuilder, Kind, Tag}; use serde::Deserialize; +use sprout_sdk::kind::{ + KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, KIND_FOLLOW_SET, KIND_MUTE_LIST, + KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST, +}; use crate::client::SproutClient; use crate::error::CliError; @@ -112,6 +117,90 @@ pub async fn cmd_get_contact_list(client: &SproutClient, pubkey: &str) -> Result Ok(()) } +fn validate_social_list_kind(kind: u32) -> Result<(), CliError> { + match kind { + KIND_MUTE_LIST + | KIND_PIN_LIST + | KIND_NIP65_RELAY_LIST_METADATA + | KIND_BOOKMARK_LIST + | KIND_FOLLOW_SET + | KIND_BOOKMARK_SET => Ok(()), + _ => Err(CliError::Usage(format!( + "unsupported social list kind {kind}; supported kinds: 10000, 10001, 10002, 10003, 30000, 30003" + ))), + } +} + +fn is_parameterized_social_list_kind(kind: u32) -> bool { + matches!(kind, KIND_FOLLOW_SET | KIND_BOOKMARK_SET) +} + +fn parse_tags_json(tags_json: &str) -> Result, CliError> { + let raw_tags: Vec> = serde_json::from_str(tags_json) + .map_err(|e| CliError::Usage(format!("invalid tags JSON: {e}")))?; + raw_tags + .iter() + .map(|parts| { + let refs: Vec<&str> = parts.iter().map(String::as_str).collect(); + Tag::parse(&refs).map_err(|e| CliError::Usage(format!("invalid tag {parts:?}: {e}"))) + }) + .collect::>() +} + +fn has_d_tag(tags: &[Tag]) -> bool { + tags.iter() + .any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("d")) +} + +pub async fn cmd_set_list( + client: &SproutClient, + kind: u16, + tags_json: &str, + content: &str, +) -> Result<(), CliError> { + let kind_u32 = u32::from(kind); + validate_social_list_kind(kind_u32)?; + let tags = parse_tags_json(tags_json)?; + if is_parameterized_social_list_kind(kind_u32) && !has_d_tag(&tags) { + return Err(CliError::Usage(format!( + "kind {kind} is parameterized replaceable and requires a d tag" + ))); + } + + let builder = EventBuilder::new(Kind::Custom(kind), content, tags); + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + println!("{resp}"); + Ok(()) +} + +pub async fn cmd_get_list( + client: &SproutClient, + pubkey: &str, + kind: u32, + d_tag: Option<&str>, +) -> Result<(), CliError> { + validate_hex64(pubkey)?; + validate_social_list_kind(kind)?; + if !is_parameterized_social_list_kind(kind) && d_tag.is_some() { + return Err(CliError::Usage(format!( + "kind {kind} is not parameterized; omit --d-tag" + ))); + } + + let mut filter = serde_json::json!({ + "kinds": [kind], + "authors": [pubkey], + "limit": 10 + }); + if let Some(d) = d_tag { + filter["#d"] = serde_json::json!([d]); + } + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) +} + // --------------------------------------------------------------------------- // Dispatch // --------------------------------------------------------------------------- @@ -130,5 +219,62 @@ pub async fn dispatch(cmd: crate::SocialCmd, client: &SproutClient) -> Result<() before, } => cmd_get_user_notes(client, &pubkey, limit, before).await, SocialCmd::GetContactList { pubkey } => cmd_get_contact_list(client, &pubkey).await, + SocialCmd::SetList { + kind, + tags, + content, + } => cmd_set_list(client, kind, &tags, &content).await, + SocialCmd::GetList { + pubkey, + kind, + d_tag, + } => cmd_get_list(client, &pubkey, kind, d_tag.as_deref()).await, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn social_list_kind_validation_accepts_supported_kinds() { + for kind in [ + KIND_MUTE_LIST, + KIND_PIN_LIST, + KIND_NIP65_RELAY_LIST_METADATA, + KIND_BOOKMARK_LIST, + KIND_FOLLOW_SET, + KIND_BOOKMARK_SET, + ] { + assert!(validate_social_list_kind(kind).is_ok(), "kind {kind}"); + } + } + + #[test] + fn social_list_kind_validation_rejects_unsupported_kinds() { + let err = validate_social_list_kind(30002).unwrap_err(); + assert!( + matches!(err, CliError::Usage(msg) if msg.contains("unsupported social list kind 30002")) + ); + } + + #[test] + fn parses_tags_json_and_detects_d_tag() { + let tags = parse_tags_json(r#"[["d","friends"],["p","aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]]"#) + .expect("tags parse"); + assert!(has_d_tag(&tags)); + } + + #[test] + fn malformed_tags_json_is_usage_error() { + let err = parse_tags_json("not json").unwrap_err(); + assert!(matches!(err, CliError::Usage(msg) if msg.contains("invalid tags JSON"))); + } + + #[test] + fn parameterized_social_list_kind_detection() { + assert!(is_parameterized_social_list_kind(KIND_FOLLOW_SET)); + assert!(is_parameterized_social_list_kind(KIND_BOOKMARK_SET)); + assert!(!is_parameterized_social_list_kind(KIND_MUTE_LIST)); } } diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs index effa431ec..660697607 100644 --- a/crates/sprout-cli/src/lib.rs +++ b/crates/sprout-cli/src/lib.rs @@ -746,6 +746,32 @@ pub enum SocialCmd { #[arg(long)] pubkey: String, }, + /// Publish a NIP-51/NIP-65 social list or set. + #[command(name = "set-list")] + SetList { + /// Supported kind: 10000, 10001, 10002, 10003, 30000, or 30003. + #[arg(long)] + kind: u16, + /// JSON array of Nostr tags, e.g. [["p",""],["d","friends"]]. + #[arg(long)] + tags: String, + /// Event content. + #[arg(long, default_value = "")] + content: String, + }, + /// Get NIP-51/NIP-65 social lists or sets by author and kind. + #[command(name = "list")] + GetList { + /// 64-char hex pubkey of the author. + #[arg(long)] + pubkey: String, + /// Supported kind: 10000, 10001, 10002, 10003, 30000, or 30003. + #[arg(long)] + kind: u32, + /// Optional d-tag for parameterized replaceable sets. + #[arg(long)] + d_tag: Option, + }, } // --------------------------------------------------------------------------- @@ -1041,7 +1067,15 @@ mod tests { assert_eq!(names(&cmd, "feed"), vec!["get"]); assert_eq!( names(&cmd, "social"), - vec!["contacts", "event", "notes", "publish", "set-contacts"] + vec![ + "contacts", + "event", + "list", + "notes", + "publish", + "set-contacts", + "set-list" + ] ); assert_eq!(names(&cmd, "repos"), vec!["create", "get", "list"]); assert_eq!(names(&cmd, "upload"), vec!["file"]); @@ -1059,7 +1093,7 @@ mod tests { ("pack", 2), ("reactions", 3), ("repos", 3), - ("social", 5), + ("social", 7), ("upload", 1), ("users", 4), ("workflows", 8), diff --git a/crates/sprout-core/src/kind.rs b/crates/sprout-core/src/kind.rs index 3ac6bcec1..570a3e4a6 100644 --- a/crates/sprout-core/src/kind.rs +++ b/crates/sprout-core/src/kind.rs @@ -11,6 +11,34 @@ pub const KIND_PROFILE: u32 = 0; pub const KIND_TEXT_NOTE: u32 = 1; /// NIP-02: Contact list / follow list. pub const KIND_CONTACT_LIST: u32 = 3; +/// NIP-51: Mute list (replaceable, 10000–19999 range) — pubkeys/events/threads/words a user has muted. +/// +/// User-owned global state, keyed by `(pubkey, kind)`. Same ownership/scope shape as kind:3. +pub const KIND_MUTE_LIST: u32 = 10000; +/// NIP-51: Pin list (replaceable) — events the user has pinned to their profile. +/// +/// User-owned global state, keyed by `(pubkey, kind)`. The events referenced may live in +/// channels, but the pin list itself is profile-level state. +pub const KIND_PIN_LIST: u32 = 10001; +/// NIP-65: Relay list metadata (replaceable) — read/write relay preferences for the outbox model. +/// +/// User-owned global state, keyed by `(pubkey, kind)`. Tags are `["r", url]` or +/// `["r", url, "read"]` / `["r", url, "write"]`. +pub const KIND_NIP65_RELAY_LIST_METADATA: u32 = 10002; +/// NIP-51: Bookmark list (replaceable) — events/articles/hashtags/URLs the user has bookmarked. +/// +/// User-owned global state, keyed by `(pubkey, kind)`. References content but is not itself +/// channel-scoped content. +pub const KIND_BOOKMARK_LIST: u32 = 10003; +/// NIP-51: Follow set (parameterized replaceable, 30000–39999 range) — named curated lists of pubkeys. +/// +/// User-owned, keyed by `(pubkey, kind, d_tag)`. Allows multiple named follow lists on top of +/// the single kind:3 contact list (e.g. "close-friends", "news", "devs"). +pub const KIND_FOLLOW_SET: u32 = 30000; +/// NIP-51: Bookmark set (parameterized replaceable) — named curated bookmark collections. +/// +/// User-owned, keyed by `(pubkey, kind, d_tag)`. +pub const KIND_BOOKMARK_SET: u32 = 30003; /// NIP-01: Channel metadata (replaceable). Not used by Sprout today. pub const KIND_CHANNEL_METADATA: u32 = 41; /// NIP-09: Event deletion request. @@ -322,6 +350,12 @@ pub const ALL_KINDS: &[u32] = &[ KIND_PROFILE, KIND_TEXT_NOTE, KIND_CONTACT_LIST, + KIND_MUTE_LIST, + KIND_PIN_LIST, + KIND_NIP65_RELAY_LIST_METADATA, + KIND_BOOKMARK_LIST, + KIND_FOLLOW_SET, + KIND_BOOKMARK_SET, KIND_CHANNEL_METADATA, KIND_DELETION, KIND_REACTION, diff --git a/crates/sprout-relay/src/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs index 47235e9b7..3c53c6c05 100644 --- a/crates/sprout-relay/src/handlers/ingest.rs +++ b/crates/sprout-relay/src/handlers/ingest.rs @@ -13,23 +13,24 @@ use nostr::Event; use sprout_auth::Scope; use sprout_core::kind::{ event_kind_u32, is_parameterized_replaceable, is_relay_admin_kind, KIND_AGENT_ENGRAM, - KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_CANVAS, KIND_CONTACT_LIST, - KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, KIND_FORUM_COMMENT, - KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, - KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, - KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, - KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, - KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE, KIND_HUDDLE_STARTED, - KIND_HUDDLE_TRACK_PUBLISHED, 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_NIP43_LEAVE_REQUEST, 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_USER_STATUS, - KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, - RELAY_ADMIN_REMOVE_MEMBER, + KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, + KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, + KIND_FOLLOW_SET, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, + KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, + KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, + KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, + KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE, + KIND_HUDDLE_STARTED, KIND_HUDDLE_TRACK_PUBLISHED, KIND_LONG_FORM, + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MUTE_LIST, + 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_NIP43_LEAVE_REQUEST, + KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST, 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_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, + RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, }; use sprout_core::verification::verify_event; @@ -153,6 +154,14 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result { Ok(Scope::UsersWrite) } + // NIP-51 standard lists and NIP-65 relay list — user-owned global state, + // same ownership shape as kind:3 (contacts) and kind:0 (profile). + KIND_MUTE_LIST + | KIND_PIN_LIST + | KIND_NIP65_RELAY_LIST_METADATA + | KIND_BOOKMARK_LIST + | KIND_FOLLOW_SET + | KIND_BOOKMARK_SET => Ok(Scope::UsersWrite), KIND_DELETION | KIND_REACTION | KIND_GIFT_WRAP @@ -302,6 +311,15 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_LONG_FORM | KIND_USER_STATUS | KIND_READ_STATE + // NIP-51 standard lists + sets and NIP-65 relay list — user-owned global state. + // Same as kind:3 (contacts): keyed by (pubkey, kind) or (pubkey, kind, d_tag), + // never channel-scoped. A stray `h` tag must not channel-scope them. + | KIND_MUTE_LIST + | KIND_PIN_LIST + | KIND_NIP65_RELAY_LIST_METADATA + | KIND_BOOKMARK_LIST + | KIND_FOLLOW_SET + | KIND_BOOKMARK_SET // NIP-AE agent engrams are addressed by (pubkey_a, kind, d_tag); never channel-scoped. | KIND_AGENT_ENGRAM // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). @@ -1840,6 +1858,13 @@ mod tests { KIND_FORUM_COMMENT, KIND_LONG_FORM, KIND_USER_STATUS, + // NIP-51 lists + sets, NIP-65 relay list + KIND_MUTE_LIST, + KIND_PIN_LIST, + KIND_NIP65_RELAY_LIST_METADATA, + KIND_BOOKMARK_LIST, + KIND_FOLLOW_SET, + KIND_BOOKMARK_SET, KIND_AGENT_ENGRAM, ]; for kind in migrated { @@ -1850,6 +1875,46 @@ mod tests { } } + #[test] + fn nip51_and_nip65_lists_require_users_write() { + let dummy = make_dummy_event(); + for kind in [ + KIND_MUTE_LIST, + KIND_PIN_LIST, + KIND_NIP65_RELAY_LIST_METADATA, + KIND_BOOKMARK_LIST, + KIND_FOLLOW_SET, + KIND_BOOKMARK_SET, + ] { + assert_eq!( + required_scope_for_kind(kind, &dummy).ok(), + Some(Scope::UsersWrite), + "kind {kind} should require UsersWrite scope" + ); + } + } + + #[test] + fn nip51_and_nip65_lists_are_global_only() { + for kind in [ + KIND_MUTE_LIST, + KIND_PIN_LIST, + KIND_NIP65_RELAY_LIST_METADATA, + KIND_BOOKMARK_LIST, + KIND_FOLLOW_SET, + KIND_BOOKMARK_SET, + ] { + assert!( + is_global_only_kind(kind), + "kind {kind} should be global-only (never channel-scoped)" + ); + assert!( + !requires_h_channel_scope(kind), + "kind {kind} must not require an h-tag channel scope" + ); + } + } + #[test] fn unknown_kind_rejected() { let dummy = make_dummy_event();