diff --git a/crates/buzz-acp/src/prompt_project.rs b/crates/buzz-acp/src/prompt_project.rs index 9f43822b2a..0f7e3e4095 100644 --- a/crates/buzz-acp/src/prompt_project.rs +++ b/crates/buzz-acp/src/prompt_project.rs @@ -78,7 +78,7 @@ fn authoritative_channel_repos( if id.is_empty() { return None; } - let maintainers = tag_values(event, "maintainers") + let maintainers = multi_tag_values(event, "maintainers") .map(str::to_ascii_lowercase) .collect(); Some(((owner, id.to_string()), maintainers)) @@ -101,6 +101,17 @@ fn tag_values<'a>(event: &'a Value, name: &'static str) -> impl Iterator(event: &'a Value, name: &'static str) -> impl Iterator { + event + .get("tags") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_array) + .filter(move |tag| tag.first().and_then(Value::as_str) == Some(name)) + .flat_map(|tag| tag.iter().skip(1).filter_map(Value::as_str)) +} + fn event_has_tag_value(event: &Value, name: &'static str, value: &str) -> bool { tag_values(event, name).any(|candidate| candidate == value) } @@ -210,7 +221,7 @@ mod tests { &owner, "game", CHANNEL_ID, - vec![json!(["maintainers", maintainer])], + vec![json!(["maintainers", "c".repeat(64), maintainer])], )], CHANNEL_ID, ) diff --git a/crates/buzz-cli/src/commands/project_channel.rs b/crates/buzz-cli/src/commands/project_channel.rs index ed0af5bf14..8b3a7c81c5 100644 --- a/crates/buzz-cli/src/commands/project_channel.rs +++ b/crates/buzz-cli/src/commands/project_channel.rs @@ -86,8 +86,10 @@ fn repo_authorizes_project(repo: &Event, project: &Event) -> bool { let signer = project.pubkey.to_hex(); repo.pubkey.to_hex().eq_ignore_ascii_case(&signer) || repo.tags.iter().any(|tag| { - matches!(tag.as_slice(), [name, value, ..] - if name == "maintainers" && value.eq_ignore_ascii_case(&signer)) + tag.as_slice().first().map(String::as_str) == Some("maintainers") + && tag.as_slice()[1..] + .iter() + .any(|value| value.eq_ignore_ascii_case(&signer)) }) } @@ -166,6 +168,20 @@ async fn fetch_channel_repos(client: &BuzzClient, channel: &str) -> Result Result<(), CliError> { + match first_tag_value(event, "buzz-channel") { + Some(bound) if bound == channel => Ok(()), + Some(bound) => Err(CliError::Conflict(format!( + "repository {:?} is already bound to channel {bound}; pass --repo-owner and --repo-id", + first_tag_value(event, "d").unwrap_or("") + ))), + None => Err(CliError::Conflict(format!( + "repository {:?} has no channel binding; bind it to {channel} or pass --repo-owner and --repo-id", + first_tag_value(event, "d").unwrap_or("") + ))), + } +} + async fn ensure_default_repo( client: &BuzzClient, channel: &str, @@ -181,6 +197,7 @@ async fn ensure_default_repo( if let Some(existing) = crate::commands::repos::fetch_own_repo_announcement(client, &repo_id).await? { + require_repo_channel_binding(&existing, channel)?; let _ = try_add_own_repo_to_channel_project(client, channel, &repo_id).await; return Ok(ChannelProjectRepo { repo_owner: existing.pubkey.to_hex(), @@ -350,6 +367,61 @@ mod tests { )); } + #[test] + fn existing_repo_must_bind_requested_channel() { + let owner = nostr::Keys::generate(); + let requested = "11111111-1111-4111-8111-111111111111"; + let other = "22222222-2222-4222-8222-222222222222"; + let matching = signed_event( + &owner, + 30617, + vec![tag(&["d", "game"]), tag(&["buzz-channel", requested])], + ); + assert!(require_repo_channel_binding(&matching, requested).is_ok()); + + let foreign = signed_event( + &owner, + 30617, + vec![tag(&["d", "game"]), tag(&["buzz-channel", other])], + ); + assert!(matches!( + require_repo_channel_binding(&foreign, requested), + Err(CliError::Conflict(_)) + )); + } + + #[test] + fn later_maintainer_value_authorizes_project() { + let owner = nostr::Keys::generate(); + let maintainer = nostr::Keys::generate(); + let unrelated = nostr::Keys::generate().public_key().to_hex(); + let channel = "11111111-1111-4111-8111-111111111111"; + let owner_hex = owner.public_key().to_hex(); + let maintainer_hex = maintainer.public_key().to_hex(); + let repo_coord = format!("30617:{owner_hex}:game"); + let repo = signed_event( + &owner, + 30617, + vec![ + tag(&["d", "game"]), + tag(&["buzz-channel", channel]), + tag(&["maintainers", &unrelated, &maintainer_hex]), + ], + ); + let project = signed_event( + &maintainer, + 30621, + vec![ + tag(&["d", "suite"]), + tag(&["buzz-channel", channel]), + tag(&["a", &repo_coord]), + ], + ); + assert!(pick_authoritative_project(&[project], &[repo], channel) + .unwrap() + .is_some()); + } + #[test] fn parse_repo_a_tag_reads_nip34_coordinate() { let owner = "a".repeat(64);