Skip to content
Merged
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
19 changes: 15 additions & 4 deletions crates/buzz-relay/src/handlers/side_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(());
Expand All @@ -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"));
}
}
Expand Down
94 changes: 93 additions & 1 deletion crates/buzz-test-client/tests/e2e_human_edit_agent_content.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/features/channels/useChannelPaneHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useEffectEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";

import {
channelMessagesKey,
Expand Down Expand Up @@ -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}`);
},
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class _SystemMessageRow extends ConsumerWidget {
ref: ref,
message: message,
channelId: channelId,
isOwnMessage: false,
canManageMessage: false,
allMessages: null,
currentPubkey: currentPubkey,
isMember: isMember,
Expand Down
12 changes: 9 additions & 3 deletions mobile/lib/features/channels/message_actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ void showMessageActions({
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
required bool isOwnMessage,
required bool canManageMessage,
List<TimelineMessage>? allMessages,
String? currentPubkey,
bool isMember = false,
Expand Down Expand Up @@ -129,7 +129,7 @@ void showMessageActions({
Clipboard.setData(data);
},
),
if (isOwnMessage) ...[
if (canManageMessage) ...[
ListTile(
leading: const Icon(LucideIcons.pencil),
title: const Text('Edit message'),
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions mobile/lib/features/channels/thread_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down