From 621e47cf77dd75119da2c84bf21e71a0c8f4be67 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:43:13 -0500 Subject: [PATCH 1/2] fix(sdk): preserve self-targeted p tags in add-member and remove-member builders When a user adds or removes themselves as a channel member, nostr 0.44's EventBuilder::sign_with_keys silently strips the p tag matching the signer's pubkey. The relay then rejects the event as "missing p tag" because validate_admin_event's extract_p_tag returns None. This is the same class of bug as #4906 (PR #4975), which fixed build_message, build_forum_post, and build_forum_comment. The e2e test add_member_ws already uses .allow_self_tagging() with a comment explaining the behavior; the SDK builders used by the CLI did not. Added .allow_self_tagging() to build_add_member (kind 9000) and build_remove_member (kind 9001). Three new unit tests verify that self-targeted p tags survive signing for both builders, including add_member with a role tag. Closes #6568 Co-authored-by: Brad Groux Signed-off-by: Brad Groux Signed-off-by: Brad Groux Signed-off-by: Brad Groux <3053586+BradGroux@users.noreply.github.com> --- crates/buzz-sdk/src/builders.rs | 56 +++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index f43887b65b1..5cf641a9d7e 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -591,6 +591,12 @@ pub fn build_profile( } /// Build a NIP-29 add-member event (kind 9000). +/// +/// `.allow_self_tagging()` is required: a self-add (target_pubkey equals the +/// signer's pubkey) is a legitimate operation — NIP-29's PUT_USER lets a user +/// join a channel by adding themselves. Without it, nostr 0.44's +/// `EventBuilder::sign_with_keys` silently strips the `p` tag matching the +/// signer, and the relay rejects the event as "missing p tag". pub fn build_add_member( channel_id: Uuid, target_pubkey: &str, @@ -604,10 +610,14 @@ pub fn build_add_member( if let Some(r) = role { tags.push(tag(&["role", r.as_str()])?); } - Ok(EventBuilder::new(Kind::Custom(9000), "").tags(tags)) + Ok(EventBuilder::new(Kind::Custom(9000), "").tags(tags).allow_self_tagging()) } /// Build a NIP-29 remove-member event (kind 9001). +/// +/// `.allow_self_tagging()` is required for the same reason as +/// [`build_add_member`]: a self-removal (leave via remove-member) is a valid +/// operation, and without it the `p` tag is stripped during signing. pub fn build_remove_member( channel_id: Uuid, target_pubkey: &str, @@ -617,7 +627,7 @@ pub fn build_remove_member( tag(&["h", &channel_id.to_string()])?, tag(&["p", &target_pubkey.to_ascii_lowercase()])?, ]; - Ok(EventBuilder::new(Kind::Custom(9001), "").tags(tags)) + Ok(EventBuilder::new(Kind::Custom(9001), "").tags(tags).allow_self_tagging()) } /// Build a NIP-29 leave-request event (kind 9022). @@ -3063,6 +3073,48 @@ mod tests { assert!(has_tag(&ev, "p", pubkey)); } + #[test] + fn add_member_self_target_preserves_p_tag() { + // nostr 0.44 strips p tags matching the signer unless + // `.allow_self_tagging()` is set. Self-add is a valid NIP-29 + // operation (joining a channel by adding yourself). + let cid = uuid(); + let keys = Keys::generate(); + let self_pubkey = keys.public_key().to_hex(); + let builder = build_add_member(cid, &self_pubkey, None::).unwrap(); + let ev = builder.sign_with_keys(&keys).expect("sign"); + assert_eq!(ev.kind.as_u16(), 9000); + assert!( + has_tag(&ev, "p", &self_pubkey), + "self-targeted p tag must survive signing" + ); + } + + #[test] + fn add_member_self_target_with_role_preserves_p_tag() { + let cid = uuid(); + let keys = Keys::generate(); + let self_pubkey = keys.public_key().to_hex(); + let builder = build_add_member(cid, &self_pubkey, Some(MemberRole::Admin)).unwrap(); + let ev = builder.sign_with_keys(&keys).expect("sign"); + assert!(has_tag(&ev, "p", &self_pubkey)); + assert!(has_tag(&ev, "role", "admin")); + } + + #[test] + fn remove_member_self_target_preserves_p_tag() { + let cid = uuid(); + let keys = Keys::generate(); + let self_pubkey = keys.public_key().to_hex(); + let builder = build_remove_member(cid, &self_pubkey).unwrap(); + let ev = builder.sign_with_keys(&keys).expect("sign"); + assert_eq!(ev.kind.as_u16(), 9001); + assert!( + has_tag(&ev, "p", &self_pubkey), + "self-targeted p tag must survive signing" + ); + } + #[test] fn leave_happy_path() { let cid = uuid(); From 030b7e51dc1387c9562a9caae517bbb14470a6cc Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:12:29 -0500 Subject: [PATCH 2/2] fix(sdk): extend self-tagging fix to DM open and DM add-member builders Chessing234 pointed out that build_dm_add_member (kind 41011) and build_dm_open (kind 41010) have the same self-tagging bug as build_add_member/build_remove_member: nostr 0.44's EventBuilder::sign_with_keys strips p tags matching the signer's pubkey unless .allow_self_tagging() is set. For build_dm_add_member, the relay's handle_dm_add_member rejects events with no p tags, so a self-add would fail identically to the channel add-member bug this PR already fixes. For build_dm_open, the self p tag is redundant (the relay adds the signer automatically), but .allow_self_tagging() makes the signed event match the caller's intent and is consistent with the other builders. Applied .allow_self_tagging() to: - build_dm_open in buzz-sdk/src/builders.rs - build_dm_add_member in buzz-sdk/src/builders.rs - build_dm_open in desktop/src-tauri/src/events.rs - the CLI's manual DM open builder in buzz-cli/src/commands/dms.rs Added two unit tests: - dm_open_self_target_preserves_p_tag - dm_add_member_self_target_preserves_p_tag Co-authored-by: Brad Groux Signed-off-by: Brad Groux Signed-off-by: Brad Groux Signed-off-by: Brad Groux <3053586+BradGroux@users.noreply.github.com> --- crates/buzz-cli/src/commands/dms.rs | 4 ++- crates/buzz-sdk/src/builders.rs | 56 +++++++++++++++++++++++++++-- desktop/src-tauri/src/events.rs | 5 ++- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/crates/buzz-cli/src/commands/dms.rs b/crates/buzz-cli/src/commands/dms.rs index 589e4118270..8bf1e0a6a78 100644 --- a/crates/buzz-cli/src/commands/dms.rs +++ b/crates/buzz-cli/src/commands/dms.rs @@ -60,13 +60,15 @@ pub async fn cmd_open_dm(client: &BuzzClient, pubkeys: &[String]) -> Result<(), // build_dm_open doesn't accept a d-tag, so we build the event manually // using the SDK builder and add the d-tag ourselves. + // `.allow_self_tagging()` preserves a p tag matching the signer's + // pubkey, matching the SDK builder's behavior. use nostr::{EventBuilder, Kind, Tag}; let mut tags: Vec = refs .iter() .map(|pk| Tag::parse(["p", *pk]).map_err(|e| CliError::Other(format!("tag error: {e}")))) .collect::, _>>()?; tags.push(Tag::parse(["d", &dm_id]).map_err(|e| CliError::Other(format!("tag error: {e}")))?); - let builder = EventBuilder::new(Kind::Custom(41010), "").tags(tags); + let builder = EventBuilder::new(Kind::Custom(41010), "").tags(tags).allow_self_tagging(); let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 5cf641a9d7e..3ab8063e55f 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -1702,6 +1702,15 @@ pub fn build_workflow_approval( /// Build a DM open event (kind 41010). /// /// `pubkeys` must be 1–8 hex-encoded pubkeys to include in the DM conversation. +/// +/// `.allow_self_tagging()` is used for the same reason as +/// [`build_add_member`]: if the caller's own pubkey appears in the +/// `pubkeys` list, nostr 0.44's `EventBuilder::sign_with_keys` would +/// strip the self-referencing `p` tag. The relay derives the participant +/// set by combining these `p` tags with the signer's pubkey, so stripping +/// the self tag is not fatal when other pubkeys remain — but without +/// `.allow_self_tagging()` the signed event silently differs from the +/// caller's intent. pub fn build_dm_open(pubkeys: &[&str]) -> Result { if pubkeys.is_empty() || pubkeys.len() > 8 { return Err(SdkError::InvalidInput( @@ -1713,14 +1722,21 @@ pub fn build_dm_open(pubkeys: &[&str]) -> Result { let validated = check_pubkey_hex(pk, "pubkey")?; tags.push(tag(&["p", &validated])?); } - Ok(EventBuilder::new(Kind::Custom(KIND_DM_OPEN as u16), "").tags(tags)) + Ok(EventBuilder::new(Kind::Custom(KIND_DM_OPEN as u16), "").tags(tags).allow_self_tagging()) } /// Build a DM add-member event (kind 41011). +/// +/// `.allow_self_tagging()` is required for the same reason as +/// [`build_add_member`]: a self-add (adding your own pubkey to an existing +/// DM) is a valid operation, and without it the `p` tag is stripped during +/// signing. The relay's `handle_dm_add_member` rejects events with no `p` +/// tags, so the stripped event would fail with "must specify at least 1 +/// new participant in p tags". pub fn build_dm_add_member(channel_id: Uuid, pubkey: &str) -> Result { let pk = check_pubkey_hex(pubkey, "pubkey")?; let tags = vec![tag(&["h", &channel_id.to_string()])?, tag(&["p", &pk])?]; - Ok(EventBuilder::new(Kind::Custom(KIND_DM_ADD_MEMBER as u16), "").tags(tags)) + Ok(EventBuilder::new(Kind::Custom(KIND_DM_ADD_MEMBER as u16), "").tags(tags).allow_self_tagging()) } /// Build a presence update event (kind 20001). @@ -4227,6 +4243,23 @@ mod tests { assert!(matches!(err, SdkError::InvalidInput(_))); } + #[test] + fn dm_open_self_target_preserves_p_tag() { + // nostr 0.44 strips p tags matching the signer unless + // `.allow_self_tagging()` is set. A user opening a DM with + // themselves is an edge case, but the signed event should + // still carry the self-referencing p tag. + let keys = Keys::generate(); + let self_pubkey = keys.public_key().to_hex(); + let builder = build_dm_open(&[&self_pubkey]).unwrap(); + let ev = builder.sign_with_keys(&keys).expect("sign"); + assert_eq!(ev.kind.as_u16(), 41010); + assert!( + has_tag(&ev, "p", &self_pubkey), + "self-targeted p tag must survive signing" + ); + } + #[test] fn dm_add_member_happy_path() { let cid = uuid(); @@ -4243,6 +4276,25 @@ mod tests { assert!(matches!(err, SdkError::InvalidInput(_))); } + #[test] + fn dm_add_member_self_target_preserves_p_tag() { + // nostr 0.44 strips p tags matching the signer unless + // `.allow_self_tagging()` is set. Adding yourself to an existing + // DM is a valid operation, and the relay rejects events with no + // p tags, so the self p tag must survive signing. + let cid = uuid(); + let keys = Keys::generate(); + let self_pubkey = keys.public_key().to_hex(); + let builder = build_dm_add_member(cid, &self_pubkey).unwrap(); + let ev = builder.sign_with_keys(&keys).expect("sign"); + assert_eq!(ev.kind.as_u16(), 41011); + assert!(has_tag(&ev, "h", &cid.to_string())); + assert!( + has_tag(&ev, "p", &self_pubkey), + "self-targeted p tag must survive signing" + ); + } + #[test] fn presence_update_content_is_status() { let ev = sign(build_presence_update("online").unwrap()); diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 1828b3f5605..34ad67ccdd2 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -738,6 +738,9 @@ pub fn build_contact_list( /// /// Each pubkey is added as a `p` tag. The relay derives the canonical /// channel id and replies via OK message with `response:{channel_id}`. +/// +/// `.allow_self_tagging()` preserves a `p` tag matching the signer's +/// pubkey, for the same reason as the SDK's `build_dm_open`. pub fn build_dm_open(pubkeys: &[String]) -> Result { if pubkeys.is_empty() { return Err("dm_open requires at least one pubkey".into()); @@ -747,7 +750,7 @@ pub fn build_dm_open(pubkeys: &[String]) -> Result { check_pubkey(pk)?; tags.push(tag(vec!["p", &pk.to_ascii_lowercase()])?); } - Ok(EventBuilder::new(Kind::Custom(41010), "").tags(tags)) + Ok(EventBuilder::new(Kind::Custom(41010), "").tags(tags).allow_self_tagging()) } /// Kind 41012 — hide a DM channel from the user's listing.