diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index c5485721b0..3da8f3ab71 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -169,8 +169,9 @@ pub async fn handle_side_effects( /// Validate a standard NIP-09 deletion event before it is stored. /// -/// Buzz accepts standard deletions for self-authored events only. Channel -/// admin deletions continue to use kind 9005. +/// Buzz accepts standard deletions for self-authored events, plus the owning +/// human deleting their agent's events (mirrors `validate_edit_ownership`). +/// Channel admin deletions continue to use kind 9005. pub async fn validate_standard_deletion_event( tenant: &TenantContext, event: &Event, @@ -193,7 +194,12 @@ pub async fn validate_standard_deletion_event( } let target_pubkey_bytes = hex::decode(parts[1]).map_err(|_| anyhow::anyhow!("invalid pubkey in a-tag"))?; - if target_pubkey_bytes != actor_bytes { + if target_pubkey_bytes != actor_bytes + && !state + .db + .is_agent_owner(tenant.community(), &target_pubkey_bytes, &actor_bytes) + .await? + { return Err(anyhow::anyhow!("must be event author")); } return Ok(()); @@ -208,7 +214,12 @@ pub async fn validate_standard_deletion_event( let target_author = effective_message_author(&target_event.event, &state.relay_keypair.public_key()); - if target_author != actor_bytes { + if target_author != actor_bytes + && !state + .db + .is_agent_owner(tenant.community(), &target_author, &actor_bytes) + .await? + { return Err(anyhow::anyhow!("must be event author")); } } diff --git a/crates/buzz-test-client/tests/e2e_human_edit_agent_content.rs b/crates/buzz-test-client/tests/e2e_human_edit_agent_content.rs index 57a888e102..0e11e567d6 100644 --- a/crates/buzz-test-client/tests/e2e_human_edit_agent_content.rs +++ b/crates/buzz-test-client/tests/e2e_human_edit_agent_content.rs @@ -1,7 +1,8 @@ //! End-to-end tests for human owners editing/managing content authored by -//! their agents — all four authorization predicate sites: +//! their agents — all five authorization predicate sites: //! //! - kind:40003 message edit (`validate_edit_ownership`) +//! - kind:5 standard deletion (`validate_standard_deletion_event`) //! - kind:9005 DELETE_EVENT (`validate_admin_event` 9005 branch) //! - kind:9002 EDIT_METADATA privileged-tag branch //! - kind:9008 DELETE_GROUP @@ -309,6 +310,97 @@ async fn test_third_party_cannot_delete_agent_message() { third_party_client.disconnect().await.ok(); } +// ─── kind:5 standard deletion ─────────────────────────────────────────────── + +/// Owner can delete a message authored by their agent via standard NIP-09 +/// kind:5 (the deletion kind the desktop app sends). +#[tokio::test] +#[ignore] +async fn test_owner_can_delete_agent_message_kind5() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let channel_id = create_agent_owned_channel(&agent_keys).await; + + let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await; + + let content = format!("agent-msg-{}", uuid::Uuid::new_v4()); + let ok = agent_client + .send_text_message(&agent_keys, &channel_id, &content, 9) + .await + .expect("agent send message"); + assert!(ok.accepted, "agent message rejected: {}", ok.message); + let msg_event_id = ok.event_id; + + let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys) + .await + .expect("connect owner"); + + let delete_event = EventBuilder::new(Kind::Custom(5), "") + .tags(vec![ + Tag::parse(["e", &msg_event_id]).unwrap(), + Tag::parse(["h", &channel_id]).unwrap(), + ]) + .sign_with_keys(&owner_keys) + .unwrap(); + + let ok = owner_client + .send_event(delete_event) + .await + .expect("send delete"); + assert!( + ok.accepted, + "owner kind:5 delete of agent message rejected: {}", + ok.message + ); + + agent_client.disconnect().await.ok(); + owner_client.disconnect().await.ok(); +} + +/// An unrelated third party cannot delete an agent's message via kind:5. +#[tokio::test] +#[ignore] +async fn test_third_party_cannot_delete_agent_message_kind5() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let third_party_keys = Keys::generate(); + let channel_id = create_agent_owned_channel(&agent_keys).await; + + let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await; + + let content = format!("agent-msg-{}", uuid::Uuid::new_v4()); + let ok = agent_client + .send_text_message(&agent_keys, &channel_id, &content, 9) + .await + .expect("agent send message"); + assert!(ok.accepted, "agent message rejected: {}", ok.message); + let msg_event_id = ok.event_id; + + let mut third_party_client = BuzzTestClient::connect(&relay_url(), &third_party_keys) + .await + .expect("connect third party"); + + let delete_event = EventBuilder::new(Kind::Custom(5), "") + .tags(vec![ + Tag::parse(["e", &msg_event_id]).unwrap(), + Tag::parse(["h", &channel_id]).unwrap(), + ]) + .sign_with_keys(&third_party_keys) + .unwrap(); + + let ok = third_party_client + .send_event(delete_event) + .await + .expect("send delete attempt"); + assert!( + !ok.accepted, + "third party should NOT be able to kind:5-delete agent message, but was accepted" + ); + + agent_client.disconnect().await.ok(); + third_party_client.disconnect().await.ok(); +} + // ─── kind:9002 EDIT_METADATA ──────────────────────────────────────────────── /// Owner can edit metadata (name/archived) of a channel owned by their agent, diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index efe36c70b2..078b0aff61 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -112,7 +112,8 @@ export function useChannelPaneHandlers({ }, [setEditTargetId]); const handleDelete = React.useCallback(async (message: { id: string }) => { - await deleteMutateRef.current({ eventId: message.id }); + // Failure is surfaced via the mutation's onError toast. + await deleteMutateRef.current({ eventId: message.id }).catch(() => {}); }, []); const handleEdit = React.useCallback( diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index f262d9e79e..0517d25d75 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -1,5 +1,6 @@ import { useEffect, useEffectEvent } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; import { channelMessagesKey, @@ -703,6 +704,9 @@ export function useDeleteMessageMutation(channel: Channel | null) { (current = []) => current.filter((message) => message.id !== eventId), ); }, + onError: (error) => { + toast.error(`Failed to delete message: ${error.message}`); + }, }); } diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 22e3a87971..0226316f71 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -47,8 +47,10 @@ class _MessageBubble extends ConsumerWidget { ref: ref, message: message, channelId: currentChannelId, - isOwnMessage: - currentPubkey?.toLowerCase() == message.pubkey.toLowerCase(), + canManageMessage: + currentPubkey?.toLowerCase() == pk || + (profile?.ownerPubkey != null && + profile?.ownerPubkey == currentPubkey?.toLowerCase()), allMessages: allMessages, currentPubkey: currentPubkey, isMember: isMember, diff --git a/mobile/lib/features/channels/channel_detail_page/system_rows.dart b/mobile/lib/features/channels/channel_detail_page/system_rows.dart index 6420e7e936..f2dcb420d6 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -41,7 +41,7 @@ class _SystemMessageRow extends ConsumerWidget { ref: ref, message: message, channelId: channelId, - isOwnMessage: false, + canManageMessage: false, allMessages: null, currentPubkey: currentPubkey, isMember: isMember, diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index 4ff6a13247..fe03bcb65e 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -25,7 +25,7 @@ void showMessageActions({ required WidgetRef ref, required TimelineMessage message, required String channelId, - required bool isOwnMessage, + required bool canManageMessage, List? allMessages, String? currentPubkey, bool isMember = false, @@ -129,7 +129,7 @@ void showMessageActions({ Clipboard.setData(data); }, ), - if (isOwnMessage) ...[ + if (canManageMessage) ...[ ListTile( leading: const Icon(LucideIcons.pencil), title: const Text('Edit message'), @@ -256,9 +256,15 @@ void _confirmDelete({ FilledButton( onPressed: () { Navigator.of(dialogContext).pop(); + final messenger = ScaffoldMessenger.of(context); ref .read(channelActionsProvider) - .deleteMessage(channelId: channelId, eventId: messageId); + .deleteMessage(channelId: channelId, eventId: messageId) + .catchError((Object error) { + messenger.showSnackBar( + SnackBar(content: Text('Failed to delete message: $error')), + ); + }); }, style: FilledButton.styleFrom( backgroundColor: dialogContext.colors.error, diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 04522418a4..711508d734 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -401,8 +401,10 @@ class _ThreadMessage extends ConsumerWidget { ref: ref, message: message, channelId: channelId, - isOwnMessage: - currentPubkey?.toLowerCase() == message.pubkey.toLowerCase(), + canManageMessage: + currentPubkey?.toLowerCase() == pk || + (profile?.ownerPubkey != null && + profile?.ownerPubkey == currentPubkey?.toLowerCase()), allMessages: allMessages, currentPubkey: currentPubkey, isMember: isMember,