From 75f5cd868c59f5164d3fb8ba67232413391d1a6c Mon Sep 17 00:00:00 2001 From: Sol <49aa1f65411fd096d2e2ec144f1e7aa36fdc76d1b907cfdf7be000c66f9d3b8e@buzz.block.builderlab.xyz> Date: Fri, 25 Sep 2026 13:33:18 -0400 Subject: [PATCH] fix(channels): let owner agents rename channels Signed-off-by: Sol <49aa1f65411fd096d2e2ec144f1e7aa36fdc76d1b907cfdf7be000c66f9d3b8e@buzz.block.builderlab.xyz> --- .../buzz-relay/src/handlers/channel_authz.rs | 19 ++++++ .../buzz-relay/src/handlers/side_effects.rs | 62 ++++++++++++++----- 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/crates/buzz-relay/src/handlers/channel_authz.rs b/crates/buzz-relay/src/handlers/channel_authz.rs index 08de2debc9..eeb7621abd 100644 --- a/crates/buzz-relay/src/handlers/channel_authz.rs +++ b/crates/buzz-relay/src/handlers/channel_authz.rs @@ -70,6 +70,20 @@ pub fn is_sole_owner(members: &[MemberRecord], pubkey: &[u8]) -> bool { sole && owners.next().is_none() } +/// Decide whether an actor may change privileged channel metadata. +/// +/// The booleans are resolved by the caller from the channel roster and the +/// immutable agent-owner mapping. +pub fn can_edit_privileged_metadata( + actor_role: Option<&str>, + actor_owns_owner_agent: bool, + actor_is_owned_by_elevated_member: bool, +) -> bool { + matches!(actor_role, Some("owner" | "admin")) + || actor_owns_owner_agent + || actor_is_owned_by_elevated_member +} + /// Decide whether `actor` may remove themselves from the channel. /// /// Shared by kind:9001 self-removal and kind:9022 leave — they enforced @@ -640,6 +654,11 @@ mod tests { } } + #[test] + fn owner_agent_may_edit_privileged_metadata_for_its_human_owner() { + assert!(can_edit_privileged_metadata(None, false, true)); + } + /// The wire contract: these strings reach NIP-29 clients verbatim, and the /// two last-owner phrasings are deliberately distinct. #[test] diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index f63c174d96..866083c28b 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -441,6 +441,27 @@ async fn actor_owns_any_owner_agent( Ok(false) } +/// Returns `true` if `actor_bytes` is an agent whose owning human is an active +/// owner member. This grants an owner's managed agent the same metadata +/// authority without requiring the agent itself to be added to the channel. +async fn actor_is_owned_by_channel_owner( + state: &Arc, + community_id: buzz_core::CommunityId, + members: &[buzz_db::channel::MemberRecord], + actor_bytes: &[u8], +) -> anyhow::Result { + for member in members.iter().filter(|member| member.role == "owner") { + if state + .db + .is_agent_owner(community_id, actor_bytes, &member.pubkey) + .await? + { + return Ok(true); + } + } + Ok(false) +} + /// Validate an admin kind event BEFORE storage. pub async fn validate_admin_event( tenant: &TenantContext, @@ -664,27 +685,38 @@ pub async fn validate_admin_event( }); if has_privileged_tag { let members = state.db.get_members(tenant.community(), channel_id).await?; - let actor_member = members.iter().find(|m| m.pubkey == actor_bytes); - match actor_member { - Some(m) if m.role == "owner" || m.role == "admin" => Ok(()), - _ => { - // Allow the owning human of any active owner-role agent in the - // channel, even when the human is not a channel member — - // diverges from kind:9001 intentionally. - if actor_owns_any_owner_agent( + let actor_role = members + .iter() + .find(|member| member.pubkey == actor_bytes) + .map(|member| member.role.as_str()); + let actor_owns_owner_agent = if matches!(actor_role, Some("owner" | "admin")) { + false + } else { + actor_owns_any_owner_agent(state, tenant.community(), &members, &actor_bytes) + .await? + }; + let actor_is_owned_by_channel_owner = + if actor_role.is_some() || actor_owns_owner_agent { + false + } else { + actor_is_owned_by_channel_owner( state, tenant.community(), &members, &actor_bytes, ) .await? - { - return Ok(()); - } - Err(anyhow::anyhow!( - "actor not authorized for name/about/archived/visibility/ttl changes" - )) - } + }; + if channel_authz::can_edit_privileged_metadata( + actor_role, + actor_owns_owner_agent, + actor_is_owned_by_channel_owner, + ) { + Ok(()) + } else { + Err(anyhow::anyhow!( + "actor not authorized for name/about/archived/visibility/ttl changes" + )) } } else { // topic/purpose: any member