Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions crates/buzz-sdk/src/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,12 @@ pub fn build_profile(
}

/// Build a NIP-29 add-member event (kind 9000).
///
/// `.allow_self_tagging()` is required: self-add is a first-class relay path
/// (`side_effects.rs` — "Self-add: always allowed regardless of policy"), so
/// `actor == target` and the `["p", target]` tag matches the signer. nostr
/// 0.44 strips matching `p` tags by default, which would make a self-targeted
/// add reach the relay with no target at all and fail as `missing p tag`.
pub fn build_add_member(
channel_id: Uuid,
target_pubkey: &str,
Expand All @@ -575,10 +581,16 @@ 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).
///
/// Self-remove is a supported relay path (allowed unless the actor is the last
/// owner), so this needs `.allow_self_tagging()` for the same reason as
/// [`build_add_member`].
pub fn build_remove_member(
channel_id: Uuid,
target_pubkey: &str,
Expand All @@ -588,7 +600,9 @@ 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).
Expand Down Expand Up @@ -2389,6 +2403,45 @@ mod tests {
assert!(tag_values(&ev, "role").is_empty());
}

/// Self-add is a supported relay path ("Self-add: always allowed
/// regardless of policy"), so the `p` tag must survive signing even when
/// it names the signer. Without `.allow_self_tagging()` nostr 0.44 strips
/// it and the relay rejects the event as `invalid: missing p tag`.
#[test]
fn add_member_self_targeted_keeps_p_tag() {
let keys = keys();
let self_hex = keys.public_key().to_hex();
let ev = build_add_member(uuid(), &self_hex, Some(MemberRole::Bot))
.unwrap()
.sign_with_keys(&keys)
.unwrap();
assert_eq!(ev.kind.as_u16(), 9000);
assert!(
has_tag(&ev, "p", &self_hex),
"self `p` tag was stripped at signing: {:?}",
ev.tags.iter().map(|t| t.as_slice()).collect::<Vec<_>>()
);
assert!(has_tag(&ev, "role", "bot"));
}

/// Self-remove is allowed unless the actor is the last owner — same
/// stripping hazard as [`add_member_self_targeted_keeps_p_tag`].
#[test]
fn remove_member_self_targeted_keeps_p_tag() {
let keys = keys();
let self_hex = keys.public_key().to_hex();
let ev = build_remove_member(uuid(), &self_hex)
.unwrap()
.sign_with_keys(&keys)
.unwrap();
assert_eq!(ev.kind.as_u16(), 9001);
assert!(
has_tag(&ev, "p", &self_hex),
"self `p` tag was stripped at signing: {:?}",
ev.tags.iter().map(|t| t.as_slice()).collect::<Vec<_>>()
);
}

#[test]
fn remove_member_happy_path() {
let cid = uuid();
Expand Down
69 changes: 69 additions & 0 deletions desktop/src-tauri/src/commands/project_git_workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,13 @@ fn build_merged_status_event(
.map(Tag::parse)
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("build merged status tags: {error}"))?;
// `.allow_self_tagging()`: `owner` is this signer's own pubkey, so nostr
// 0.44 would strip `["p", owner]` and ship the status event without the
// recipient the readers index on.
EventBuilder::new(Kind::Custom(1631), "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.allow_self_tagging()
.sign_with_keys(keys)
.map(|event| event.as_json())
.map_err(|error| format!("sign merged pull request status: {error}"))
Expand Down Expand Up @@ -291,9 +295,12 @@ fn build_pull_request_status_event(
.map(Tag::parse)
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("build pull request status tags: {error}"))?;
// See `build_merged_status_event` — `owner` is the signer, so the
// `["p", owner]` tag needs `.allow_self_tagging()` to survive.
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at.max(Timestamp::now().as_secs())))
.allow_self_tagging()
.sign_with_keys(keys)
.map(|event| event.as_json())
.map_err(|error| format!("sign pull request status: {error}"))
Expand Down Expand Up @@ -830,6 +837,68 @@ mod tests {
assert!(other.recovery.is_none());
}

/// The owner `p` tag names the signer, so nostr 0.44 scrubs it unless the
/// builder opts into self-tagging. Readers index PR status events by
/// `#p` (`projectPullRequests.mjs` reads them back as `recipients`), so a
/// stripped tag silently drops the owner from the participant set.
#[test]
fn pull_request_status_events_keep_the_owner_p_tag() {
let keys = Keys::generate();
let owner = keys.public_key().to_hex();
let repo_address = format!("30617:{owner}:buzz");
let author = "b".repeat(64);

let merged = Event::from_json(
build_merged_status_event(
&keys,
&repo_address,
&"d".repeat(64),
&author,
&"e".repeat(40),
123,
)
.unwrap(),
)
.unwrap();
assert!(
merged
.tags
.iter()
.any(|tag| tag.as_slice() == ["p", owner.as_str()]),
"kind 1631 lost the owner `p` tag: {:?}",
merged.tags
);

for (status, kind) in [("open", 1630u16), ("closed", 1632), ("draft", 1633)] {
let event = Event::from_json(
build_pull_request_status_event(
&keys,
&repo_address,
&"d".repeat(64),
&author,
status,
123,
)
.unwrap(),
)
.unwrap();
assert_eq!(event.kind.as_u16(), kind);
assert!(
event
.tags
.iter()
.any(|tag| tag.as_slice() == ["p", owner.as_str()]),
"kind {kind} ({status}) lost the owner `p` tag: {:?}",
event.tags
);
// The author is a distinct pubkey, so it must still ride along.
assert!(event
.tags
.iter()
.any(|tag| tag.as_slice() == ["p", author.as_str()]));
}
}

#[test]
fn merged_status_is_signed_by_repository_owner() {
let keys = Keys::generate();
Expand Down
68 changes: 66 additions & 2 deletions desktop/src-tauri/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,11 @@ pub fn build_delete_channel(channel_id: Uuid) -> Result<EventBuilder, String> {
// ── Membership ───────────────────────────────────────────────────────────────

/// Kind 9000 — add member.
///
/// `.allow_self_tagging()` is required: self-add is a first-class relay path,
/// so `actor == target` and the `["p", target]` tag matches the signer. nostr
/// 0.44 strips matching `p` tags by default, which would send the event with
/// no target and fail as `missing p tag`.
pub fn build_add_member(
channel_id: Uuid,
target_pubkey: &str,
Expand All @@ -279,17 +284,24 @@ pub fn build_add_member(
if let Some(r) = role {
tags.push(tag(vec!["role", r])?);
}
Ok(EventBuilder::new(Kind::Custom(9000), "").tags(tags))
Ok(EventBuilder::new(Kind::Custom(9000), "")
.tags(tags)
.allow_self_tagging())
}

/// Kind 9001 — remove member.
///
/// Self-remove is a supported relay path, so this needs
/// `.allow_self_tagging()` for the same reason as [`build_add_member`].
pub fn build_remove_member(channel_id: Uuid, target_pubkey: &str) -> Result<EventBuilder, String> {
check_pubkey(target_pubkey)?;
let tags = vec![
tag(vec!["h", &channel_id.to_string()])?,
tag(vec!["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())
}

// ── Messages ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -931,6 +943,58 @@ mod tests {
assert_eq!(event.pubkey.to_hex(), TARGET_HEX);
}

// ── Self-targeted membership keeps its `p` tag ──────────────────────
//
// Self-add and self-remove are supported relay paths, so `actor ==
// target`. Without `.allow_self_tagging()` nostr 0.44 scrubs the `p` tag
// that names the signer and the relay rejects with `missing p tag`.

fn self_membership_tags(builder: EventBuilder) -> (Vec<Vec<String>>, String) {
let secret = nostr::SecretKey::from_hex(
"0000000000000000000000000000000000000000000000000000000000000004",
)
.unwrap();
let keys = Keys::new(secret);
let event = builder.sign_with_keys(&keys).unwrap();
(
event.tags.iter().map(|t| t.as_slice().to_vec()).collect(),
keys.public_key().to_hex(),
)
}

#[test]
fn add_member_self_targeted_keeps_p_tag() {
let secret = nostr::SecretKey::from_hex(
"0000000000000000000000000000000000000000000000000000000000000004",
)
.unwrap();
let self_hex = Keys::new(secret).public_key().to_hex();
let channel = Uuid::parse_str(CH_ID).unwrap();
let (tags, signer) =
self_membership_tags(build_add_member(channel, &self_hex, Some("bot")).unwrap());
assert!(
tags.iter().any(|t| t[0] == "p" && t[1] == signer),
"self `p` tag was stripped at signing: {tags:?}"
);
assert!(tags.iter().any(|t| t[0] == "role" && t[1] == "bot"));
}

#[test]
fn remove_member_self_targeted_keeps_p_tag() {
let secret = nostr::SecretKey::from_hex(
"0000000000000000000000000000000000000000000000000000000000000004",
)
.unwrap();
let self_hex = Keys::new(secret).public_key().to_hex();
let channel = Uuid::parse_str(CH_ID).unwrap();
let (tags, signer) =
self_membership_tags(build_remove_member(channel, &self_hex).unwrap());
assert!(
tags.iter().any(|t| t[0] == "p" && t[1] == signer),
"self `p` tag was stripped at signing: {tags:?}"
);
}

// ── build_message_edit `p`-tag emission (lane 8ace8eed) ──────────────
//
// The composer diffs the edited body's mentions against the original and
Expand Down