fix: let agent owners delete their agent's messages (relay kind:5 + desktop/mobile UX) (#1519)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-06 06:45:57 -07:00
committed by GitHub
co-authored by Brain
parent 8174f2b0a0
commit 6428005487
8 changed files with 132 additions and 14 deletions
+15 -4
View File
@@ -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"));
}
}
@@ -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,
@@ -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(
+4
View File
@@ -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}`);
},
});
}
@@ -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,
@@ -41,7 +41,7 @@ class _SystemMessageRow extends ConsumerWidget {
ref: ref,
message: message,
channelId: channelId,
isOwnMessage: false,
canManageMessage: false,
allMessages: null,
currentPubkey: currentPubkey,
isMember: isMember,
@@ -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,
@@ -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,
@@ -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,