From 7a77bc8e084f8aabecad1bc1ed467b1f14682a2d Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 9 Apr 2026 11:47:53 -0700 Subject: [PATCH 1/5] feat: allow bot owners to remove their own bots from channels Extends remove_member authorization to check agent_owner_pubkey: if the actor owns the target bot (via agent_owner_pubkey), they can remove it from any channel they're both members of, even without owner/admin role. Updated both the DB layer (remove_member) and the NIP-29 kind:9001 validation (validate_admin_event) with the same agent-owner check. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/sprout-db/src/channel.rs | 139 +++++++++++++++++- .../sprout-relay/src/handlers/side_effects.rs | 11 ++ 2 files changed, 147 insertions(+), 3 deletions(-) diff --git a/crates/sprout-db/src/channel.rs b/crates/sprout-db/src/channel.rs index d8c532f23b..3dc8b6b6a1 100644 --- a/crates/sprout-db/src/channel.rs +++ b/crates/sprout-db/src/channel.rs @@ -427,9 +427,19 @@ pub async fn remove_member( DbError::InvalidData(format!("invalid role in database: {actor_role_str}")) })?; if !actor_role.is_elevated() { - return Err(DbError::AccessDenied( - "only owners/admins may remove other members".to_string(), - )); + // Check if actor is the agent owner of the target + let is_agent_owner = if let Some((_policy, Some(owner))) = + crate::user::get_agent_channel_policy(pool, pubkey).await? + { + owner == actor_pubkey + } else { + false + }; + if !is_agent_owner { + return Err(DbError::AccessDenied( + "only owners/admins or the agent's owner may remove other members".to_string(), + )); + } } } @@ -1147,3 +1157,126 @@ pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result> }) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::user::{ensure_user, set_agent_owner}; + use nostr::Keys; + + const TEST_DB_URL: &str = "postgres://sprout:sprout_dev@localhost:5432/sprout"; + + async fn setup_pool() -> PgPool { + PgPool::connect(TEST_DB_URL) + .await + .expect("connect to test DB") + } + + fn random_pubkey() -> Vec { + Keys::generate().public_key().serialize().to_vec() + } + + /// Agent owner (non-admin) can remove their own bot from a channel. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_agent_owner_can_remove_bot() { + let pool = setup_pool().await; + let owner_pk = random_pubkey(); + let agent_pk = random_pubkey(); + + // Create users and set agent ownership + ensure_user(&pool, &owner_pk).await.expect("ensure owner"); + ensure_user(&pool, &agent_pk).await.expect("ensure agent"); + set_agent_owner(&pool, &agent_pk, &owner_pk) + .await + .expect("set agent owner"); + + // Create a channel owned by someone else entirely + let channel_owner_pk = random_pubkey(); + ensure_user(&pool, &channel_owner_pk) + .await + .expect("ensure channel owner"); + let channel = create_channel( + &pool, + "test-bot-remove", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &channel_owner_pk, + None, + ) + .await + .expect("create channel"); + + // Add owner and agent as regular members + add_member(&pool, channel.id, &owner_pk, MemberRole::Member, None) + .await + .expect("add owner as member"); + add_member(&pool, channel.id, &agent_pk, MemberRole::Member, None) + .await + .expect("add agent as member"); + + // Owner should be able to remove their agent + remove_member(&pool, channel.id, &agent_pk, &owner_pk) + .await + .expect("agent owner should be able to remove their bot"); + + // Verify the agent is no longer a member + assert!( + !is_member(&pool, channel.id, &agent_pk) + .await + .expect("is_member check"), + "agent should no longer be a member" + ); + } + + /// A random non-admin, non-owner user cannot remove someone else's bot. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_random_user_cannot_remove_bot() { + let pool = setup_pool().await; + let owner_pk = random_pubkey(); + let agent_pk = random_pubkey(); + let random_pk = random_pubkey(); + + // Create users and set agent ownership + ensure_user(&pool, &owner_pk).await.expect("ensure owner"); + ensure_user(&pool, &agent_pk).await.expect("ensure agent"); + ensure_user(&pool, &random_pk).await.expect("ensure random"); + set_agent_owner(&pool, &agent_pk, &owner_pk) + .await + .expect("set agent owner"); + + // Create a channel + let channel_owner_pk = random_pubkey(); + ensure_user(&pool, &channel_owner_pk) + .await + .expect("ensure channel owner"); + let channel = create_channel( + &pool, + "test-bot-no-remove", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &channel_owner_pk, + None, + ) + .await + .expect("create channel"); + + // Add random user and agent as regular members + add_member(&pool, channel.id, &random_pk, MemberRole::Member, None) + .await + .expect("add random as member"); + add_member(&pool, channel.id, &agent_pk, MemberRole::Member, None) + .await + .expect("add agent as member"); + + // Random user should NOT be able to remove the agent + let result = remove_member(&pool, channel.id, &agent_pk, &random_pk).await; + assert!( + result.is_err(), + "random user should not be able to remove someone else's bot" + ); + } +} diff --git a/crates/sprout-relay/src/handlers/side_effects.rs b/crates/sprout-relay/src/handlers/side_effects.rs index 5eb02f8f68..725779c0c5 100644 --- a/crates/sprout-relay/src/handlers/side_effects.rs +++ b/crates/sprout-relay/src/handlers/side_effects.rs @@ -205,6 +205,17 @@ pub async fn validate_admin_event( let actor_member = members.iter().find(|m| m.pubkey == actor_bytes); match actor_member { Some(m) if m.role == "owner" || m.role == "admin" => Ok(()), + Some(_) => { + // Check if actor is agent owner of target + if let Some((_policy, Some(owner))) = + state.db.get_agent_channel_policy(&target_pubkey).await? + { + if owner == actor_bytes { + return Ok(()); + } + } + Err(anyhow::anyhow!("actor not authorized")) + } _ => Err(anyhow::anyhow!("actor not authorized")), } } From 746f072d6c6b5a1a69f49a9d59292450f7afbd7a Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 9 Apr 2026 11:51:17 -0700 Subject: [PATCH 2/5] style: fix formatting for is_agent_owner helper Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/sprout-db/src/channel.rs | 23 +++++++------------ crates/sprout-db/src/lib.rs | 5 ++++ crates/sprout-db/src/user.rs | 11 +++++++++ .../sprout-relay/src/handlers/side_effects.rs | 14 +++++------ 4 files changed, 31 insertions(+), 22 deletions(-) diff --git a/crates/sprout-db/src/channel.rs b/crates/sprout-db/src/channel.rs index 3dc8b6b6a1..0cac5af485 100644 --- a/crates/sprout-db/src/channel.rs +++ b/crates/sprout-db/src/channel.rs @@ -405,7 +405,8 @@ pub async fn add_member( /// Remove a member from a channel (soft delete). /// -/// `actor_pubkey` must be an active owner/admin, or the member removing themselves. +/// `actor_pubkey` must be an active owner/admin, the agent's owner, or the member +/// removing themselves. /// /// Returns `Err(DbError::MemberNotFound)` if the target is not an active member. /// The authorization check and the UPDATE run inside a transaction to prevent a @@ -426,20 +427,12 @@ pub async fn remove_member( let actor_role: MemberRole = actor_role_str.parse().map_err(|_| { DbError::InvalidData(format!("invalid role in database: {actor_role_str}")) })?; - if !actor_role.is_elevated() { - // Check if actor is the agent owner of the target - let is_agent_owner = if let Some((_policy, Some(owner))) = - crate::user::get_agent_channel_policy(pool, pubkey).await? - { - owner == actor_pubkey - } else { - false - }; - if !is_agent_owner { - return Err(DbError::AccessDenied( - "only owners/admins or the agent's owner may remove other members".to_string(), - )); - } + if !actor_role.is_elevated() + && !crate::user::is_agent_owner(pool, pubkey, actor_pubkey).await? + { + return Err(DbError::AccessDenied( + "only owners/admins or the agent's owner may remove other members".to_string(), + )); } } diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index d549deb339..4063cf8a13 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -557,6 +557,11 @@ impl Db { user::get_agent_channel_policy(&self.pool, pubkey).await } + /// Check whether `actor_pubkey` is the agent owner of `target_pubkey`. + pub async fn is_agent_owner(&self, target_pubkey: &[u8], actor_pubkey: &[u8]) -> Result { + user::is_agent_owner(&self.pool, target_pubkey, actor_pubkey).await + } + /// Set the channel_add_policy for a user. pub async fn set_channel_add_policy(&self, pubkey: &[u8], policy: &str) -> Result<()> { user::set_channel_add_policy(&self.pool, pubkey, policy).await diff --git a/crates/sprout-db/src/user.rs b/crates/sprout-db/src/user.rs index c195d49920..30ef07ab04 100644 --- a/crates/sprout-db/src/user.rs +++ b/crates/sprout-db/src/user.rs @@ -324,6 +324,17 @@ pub async fn get_agent_channel_policy( .transpose() } +/// Check whether `actor_pubkey` is the `agent_owner_pubkey` of `target_pubkey`. +pub async fn is_agent_owner( + pool: &PgPool, + target_pubkey: &[u8], + actor_pubkey: &[u8], +) -> Result { + Ok( + matches!(get_agent_channel_policy(pool, target_pubkey).await?, Some((_policy, Some(owner))) if owner == actor_pubkey), + ) +} + /// Set the channel_add_policy for a user. /// Returns an error if the pubkey is not found (rows_affected == 0). /// Returns an error if `policy` is not one of the valid ENUM values. diff --git a/crates/sprout-relay/src/handlers/side_effects.rs b/crates/sprout-relay/src/handlers/side_effects.rs index 725779c0c5..e65a7e0478 100644 --- a/crates/sprout-relay/src/handlers/side_effects.rs +++ b/crates/sprout-relay/src/handlers/side_effects.rs @@ -206,15 +206,15 @@ pub async fn validate_admin_event( match actor_member { Some(m) if m.role == "owner" || m.role == "admin" => Ok(()), Some(_) => { - // Check if actor is agent owner of target - if let Some((_policy, Some(owner))) = - state.db.get_agent_channel_policy(&target_pubkey).await? + if state + .db + .is_agent_owner(&target_pubkey, &actor_bytes) + .await? { - if owner == actor_bytes { - return Ok(()); - } + Ok(()) + } else { + Err(anyhow::anyhow!("actor not authorized")) } - Err(anyhow::anyhow!("actor not authorized")) } _ => Err(anyhow::anyhow!("actor not authorized")), } From 01ef10cfa3f17dedbf62cc263392a667f2aad0ec Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 9 Apr 2026 12:10:55 -0700 Subject: [PATCH 3/5] feat: allow bot owners to remove their bots from any channel Add isMyBot() helper to useClassifiedMembers that checks if a member is in the current user's managedAgentPubkeys set. Use it in MembersSidebar so the "Remove" button appears for bots the user owns, regardless of their channel role. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src/features/channels/lib/useClassifiedMembers.ts | 8 ++++++++ desktop/src/features/channels/ui/MembersSidebar.tsx | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/channels/lib/useClassifiedMembers.ts b/desktop/src/features/channels/lib/useClassifiedMembers.ts index 00a3af09af..b7017ceef3 100644 --- a/desktop/src/features/channels/lib/useClassifiedMembers.ts +++ b/desktop/src/features/channels/lib/useClassifiedMembers.ts @@ -40,6 +40,13 @@ export function useClassifiedMembers( [managedAgentPubkeys, relayAgentPubkeys], ); + const isMyBot = React.useCallback( + (member: ChannelMember) => { + return managedAgentPubkeys.has(normalizePubkey(member.pubkey)); + }, + [managedAgentPubkeys], + ); + const { people, bots } = React.useMemo(() => { const peopleList: ChannelMember[] = []; const botList: ChannelMember[] = []; @@ -66,6 +73,7 @@ export function useClassifiedMembers( peopleCount: people.length, botCount: bots.length, isBot, + isMyBot, managedAgentsQuery, relayAgentsQuery, }; diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 678ad92336..7c4a0f93b6 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -50,7 +50,7 @@ export function MembersSidebar({ const removeMemberMutation = useRemoveChannelMemberMutation(channelId); const rawMembers = membersQuery.data ?? []; - const { people, bots, isBot } = useClassifiedMembers( + const { people, bots, isBot, isMyBot } = useClassifiedMembers( rawMembers, currentPubkey, ); @@ -81,6 +81,7 @@ export function MembersSidebar({ const canRemoveMember = (selfMember?.role === "admin" && member.pubkey !== currentPubkey) || (selfMember?.role === "owner" && isBot(member)) || + isMyBot(member) || (currentPubkey && member.pubkey === currentPubkey); const memberLabel = formatMemberName(member, currentPubkey); const profile = From 06a0390e13bd5af8ce64fc177848de32998e7a83 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 9 Apr 2026 12:13:35 -0700 Subject: [PATCH 4/5] fix: address review feedback on bot-owner remove - Add comment explaining why is_agent_owner queries pool instead of tx (agent_owner_pubkey is immutable, set at mint) - Guard isMyBot condition with selfMember check so non-members don't see a Remove button that the relay would reject Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/sprout-db/src/channel.rs | 2 ++ desktop/src/features/channels/ui/MembersSidebar.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/sprout-db/src/channel.rs b/crates/sprout-db/src/channel.rs index 0cac5af485..186299051f 100644 --- a/crates/sprout-db/src/channel.rs +++ b/crates/sprout-db/src/channel.rs @@ -427,6 +427,8 @@ pub async fn remove_member( let actor_role: MemberRole = actor_role_str.parse().map_err(|_| { DbError::InvalidData(format!("invalid role in database: {actor_role_str}")) })?; + // Safe to query outside the transaction: agent_owner_pubkey is immutable + // (set once at token mint, first-mint-wins). if !actor_role.is_elevated() && !crate::user::is_agent_owner(pool, pubkey, actor_pubkey).await? { diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 7c4a0f93b6..5531385a4b 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -81,7 +81,7 @@ export function MembersSidebar({ const canRemoveMember = (selfMember?.role === "admin" && member.pubkey !== currentPubkey) || (selfMember?.role === "owner" && isBot(member)) || - isMyBot(member) || + (selfMember && isMyBot(member)) || (currentPubkey && member.pubkey === currentPubkey); const memberLabel = formatMemberName(member, currentPubkey); const profile = From 4b159c4e208b512af9b66a96c5e3a43eb097b737 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 9 Apr 2026 12:49:50 -0700 Subject: [PATCH 5/5] Address review feedback: decouple is_agent_owner, fix docs, add comments - Replace is_agent_owner() indirect query via get_agent_channel_policy() with a direct agent_owner_pubkey comparison query - Fix stale docstring on remove_member re: transaction boundaries - Add comment explaining non-member bot owners can't remove bots - Add clarifying comment on frontend canRemoveMember logic Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/sprout-db/src/channel.rs | 4 +++- crates/sprout-db/src/user.rs | 11 +++++++++-- crates/sprout-relay/src/handlers/side_effects.rs | 3 +++ desktop/src/features/channels/ui/MembersSidebar.tsx | 1 + 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/sprout-db/src/channel.rs b/crates/sprout-db/src/channel.rs index 186299051f..d4c0b7c18c 100644 --- a/crates/sprout-db/src/channel.rs +++ b/crates/sprout-db/src/channel.rs @@ -409,8 +409,10 @@ pub async fn add_member( /// removing themselves. /// /// Returns `Err(DbError::MemberNotFound)` if the target is not an active member. -/// The authorization check and the UPDATE run inside a transaction to prevent a +/// The actor's role check and the UPDATE run inside a transaction to prevent a /// TOCTOU race where the actor's role changes between the check and the update. +/// The `is_agent_owner` check runs outside the transaction against the main pool +/// because `agent_owner_pubkey` is immutable (set once at token mint). pub async fn remove_member( pool: &PgPool, channel_id: Uuid, diff --git a/crates/sprout-db/src/user.rs b/crates/sprout-db/src/user.rs index 30ef07ab04..721e9c4e5b 100644 --- a/crates/sprout-db/src/user.rs +++ b/crates/sprout-db/src/user.rs @@ -325,14 +325,21 @@ pub async fn get_agent_channel_policy( } /// Check whether `actor_pubkey` is the `agent_owner_pubkey` of `target_pubkey`. +/// Queries `agent_owner_pubkey` directly rather than going through +/// `get_agent_channel_policy`, which would fetch unrelated fields. pub async fn is_agent_owner( pool: &PgPool, target_pubkey: &[u8], actor_pubkey: &[u8], ) -> Result { - Ok( - matches!(get_agent_channel_policy(pool, target_pubkey).await?, Some((_policy, Some(owner))) if owner == actor_pubkey), + let row = sqlx::query_scalar::<_, bool>( + "SELECT agent_owner_pubkey = $2 FROM users WHERE pubkey = $1 AND agent_owner_pubkey IS NOT NULL", ) + .bind(target_pubkey) + .bind(actor_pubkey) + .fetch_optional(pool) + .await?; + Ok(row.unwrap_or(false)) } /// Set the channel_add_policy for a user. diff --git a/crates/sprout-relay/src/handlers/side_effects.rs b/crates/sprout-relay/src/handlers/side_effects.rs index e65a7e0478..6aad9b70bc 100644 --- a/crates/sprout-relay/src/handlers/side_effects.rs +++ b/crates/sprout-relay/src/handlers/side_effects.rs @@ -216,6 +216,9 @@ pub async fn validate_admin_event( Err(anyhow::anyhow!("actor not authorized")) } } + // Non-members fall here. We intentionally do NOT check + // is_agent_owner for non-members — you must be in the channel + // to remove anyone, even your own bot. _ => Err(anyhow::anyhow!("actor not authorized")), } } diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 5531385a4b..2274426bad 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -78,6 +78,7 @@ export function MembersSidebar({ } function renderMemberCard(member: ChannelMember, memberIsBot: boolean) { + // Any channel member can remove bots they own, regardless of role. const canRemoveMember = (selfMember?.role === "admin" && member.pubkey !== currentPubkey) || (selfMember?.role === "owner" && isBot(member)) ||