mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user