mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
fix(relay): allow owner-of-agent to delete agent messages, surface delete errors in desktop
The desktop grants the delete button to self-author OR owner-of-agent via `canManageMessage`, but the relay's kind:5 validator only honored self-author. An owner deleting their agent's message received a silent relay rejection because `useDeleteMessageMutation` had no `onError` handler. Relay (validate_standard_deletion_event, side_effects.rs): - e-tag branch: when target_author != actor, fall through to is_agent_owner before rejecting. If owner, re-gate on channel membership so a removed private-channel member cannot delete their agent's historic messages after access is revoked. Open channels pass without membership. Mirrors the membership re-gate from validate_edit_ownership. - a-tag branch: same is_agent_owner fallback. Membership re-gate applies when the deletion event carries a resolvable h-tag channel context; global addressable events (no channel context) pass on owner-check alone. - Self-author fast-path unchanged: no membership re-gate on delete. Desktop (hooks.ts): - Add onError to useDeleteMessageMutation that surfaces a toast so relay rejections are visible rather than silently swallowed. Tests (side_effects.rs): five DB-backed integration tests with graceful-skip when DATABASE_URL is unavailable: - delete_own_message_succeeds - delete_owner_agent_message_as_member_succeeds - delete_non_owner_agent_message_is_rejected - delete_owner_agent_message_removed_from_private_channel_is_rejected - delete_owner_agent_message_in_open_channel_without_membership_succeeds Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
parent
228122fddc
commit
f95b7aec08
@@ -180,7 +180,7 @@ pub async fn validate_standard_deletion_event(
|
||||
let target_ids = extract_target_event_ids(event);
|
||||
|
||||
if !has_e_tag(event) {
|
||||
// a-tag deletion: verify author owns the addressable event
|
||||
// a-tag deletion: verify author owns the addressable event.
|
||||
let a_tag = event
|
||||
.tags
|
||||
.iter()
|
||||
@@ -194,7 +194,35 @@ 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 {
|
||||
return Err(anyhow::anyhow!("must be event author"));
|
||||
// Not self-author: check whether actor owns the target-pubkey agent.
|
||||
let is_owner = state
|
||||
.db
|
||||
.is_agent_owner(tenant.community(), &target_pubkey_bytes, &actor_bytes)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("db error checking agent ownership: {e}"))?;
|
||||
if !is_owner {
|
||||
return Err(anyhow::anyhow!("must be event author"));
|
||||
}
|
||||
// Owner confirmed. Re-gate on membership when a channel context is resolvable
|
||||
// (parity with the e-tag owner path below; if no channel context, owner-check alone
|
||||
// is sufficient — the addressable event may be global).
|
||||
if let Some(ch_id) = extract_h_tag_channel(event) {
|
||||
let is_member = state
|
||||
.is_member_cached(tenant.community(), ch_id, &actor_bytes)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("db error checking membership: {e}"))?;
|
||||
if !is_member {
|
||||
let is_open = state
|
||||
.db
|
||||
.get_channel(tenant.community(), ch_id)
|
||||
.await
|
||||
.map(|ch| ch.visibility == "open")
|
||||
.unwrap_or(false);
|
||||
if !is_open {
|
||||
return Err(anyhow::anyhow!("restricted: not a channel member"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -208,8 +236,39 @@ 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 {
|
||||
return Err(anyhow::anyhow!("must be event author"));
|
||||
if target_author == actor_bytes {
|
||||
// Self-author fast-path: accepted without membership re-gate, matching current
|
||||
// behavior (a removal from a private channel revokes future send access; it does
|
||||
// not retroactively block deleting their own historic messages).
|
||||
} else {
|
||||
// Not self-author: check whether actor owns the agent that authored the target.
|
||||
let is_owner = state
|
||||
.db
|
||||
.is_agent_owner(tenant.community(), &target_author, &actor_bytes)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("db error checking agent ownership: {e}"))?;
|
||||
if !is_owner {
|
||||
return Err(anyhow::anyhow!("must be event author"));
|
||||
}
|
||||
// Owner confirmed. Re-gate on channel membership so that a removed private-channel
|
||||
// member cannot delete their agent's messages after access is revoked.
|
||||
if let Some(ch_id) = target_event.channel_id {
|
||||
let is_member = state
|
||||
.is_member_cached(tenant.community(), ch_id, &actor_bytes)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("db error checking membership: {e}"))?;
|
||||
if !is_member {
|
||||
let is_open = state
|
||||
.db
|
||||
.get_channel(tenant.community(), ch_id)
|
||||
.await
|
||||
.map(|ch| ch.visibility == "open")
|
||||
.unwrap_or(false);
|
||||
if !is_open {
|
||||
return Err(anyhow::anyhow!("restricted: not a channel member"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2962,3 +3021,372 @@ fn topic_for_subscription(channel_id: Option<Uuid>) -> EventTopic {
|
||||
None => EventTopic::Global,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::validate_standard_deletion_event;
|
||||
use crate::config::Config;
|
||||
use buzz_core::tenant::TenantContext;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async fn test_pool() -> Option<sqlx::PgPool> {
|
||||
let url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".into());
|
||||
sqlx::PgPool::connect(&url).await.ok()
|
||||
}
|
||||
|
||||
async fn test_state(pool: sqlx::PgPool) -> Option<Arc<crate::state::AppState>> {
|
||||
let db = buzz_db::Db::from_pool(pool.clone());
|
||||
let config = Config::from_env().ok()?;
|
||||
let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
|
||||
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
|
||||
.ok()?;
|
||||
let pubsub = Arc::new(
|
||||
buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
|
||||
.await
|
||||
.ok()?,
|
||||
);
|
||||
let audit = buzz_audit::AuditService::new(pool.clone());
|
||||
let auth = buzz_auth::AuthService::new(config.auth.clone());
|
||||
let search = buzz_search::SearchService::new(pool.clone());
|
||||
let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
|
||||
db.clone(),
|
||||
buzz_workflow::WorkflowConfig::default(),
|
||||
));
|
||||
let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?;
|
||||
let (state, _) = crate::state::AppState::new(
|
||||
config,
|
||||
db,
|
||||
redis_pool,
|
||||
audit,
|
||||
pubsub,
|
||||
auth,
|
||||
search,
|
||||
workflow_engine,
|
||||
Keys::generate(),
|
||||
media_storage,
|
||||
);
|
||||
Some(Arc::new(state))
|
||||
}
|
||||
|
||||
async fn seed_community(pool: &sqlx::PgPool) -> TenantContext {
|
||||
let id = Uuid::new_v4();
|
||||
let host = format!("del-test-{}.example", id.simple());
|
||||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
||||
.bind(id)
|
||||
.bind(&host)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert community");
|
||||
TenantContext::resolved(buzz_core::tenant::CommunityId::from_uuid(id), host)
|
||||
}
|
||||
|
||||
/// Insert a user row (no agent_owner_pubkey).
|
||||
async fn insert_user(pool: &sqlx::PgPool, community_id: Uuid, pubkey: &[u8]) {
|
||||
sqlx::query(
|
||||
"INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert user");
|
||||
}
|
||||
|
||||
/// Insert a user row with agent_owner_pubkey set (marks `pubkey` as an agent owned by `owner`).
|
||||
async fn insert_agent_with_owner(
|
||||
pool: &sqlx::PgPool,
|
||||
community_id: Uuid,
|
||||
pubkey: &[u8],
|
||||
owner_pubkey: &[u8],
|
||||
) {
|
||||
sqlx::query(
|
||||
"INSERT INTO users (community_id, pubkey, agent_owner_pubkey) \
|
||||
VALUES ($1, $2, $3) ON CONFLICT (community_id, pubkey) DO \
|
||||
UPDATE SET agent_owner_pubkey = EXCLUDED.agent_owner_pubkey",
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(pubkey)
|
||||
.bind(owner_pubkey)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert agent with owner");
|
||||
}
|
||||
|
||||
/// Insert a private channel; returns its UUID.
|
||||
async fn insert_private_channel(pool: &sqlx::PgPool, community_id: Uuid) -> Uuid {
|
||||
let ch_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO channels (id, community_id, name, visibility, created_by) \
|
||||
VALUES ($1, $2, $3, 'private', $4)",
|
||||
)
|
||||
.bind(ch_id)
|
||||
.bind(community_id)
|
||||
.bind(format!("test-{}", ch_id.simple()))
|
||||
.bind(vec![0u8; 32])
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert channel");
|
||||
ch_id
|
||||
}
|
||||
|
||||
/// Insert an open channel; returns its UUID.
|
||||
async fn insert_open_channel(pool: &sqlx::PgPool, community_id: Uuid) -> Uuid {
|
||||
let ch_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO channels (id, community_id, name, visibility, created_by) \
|
||||
VALUES ($1, $2, $3, 'open', $4)",
|
||||
)
|
||||
.bind(ch_id)
|
||||
.bind(community_id)
|
||||
.bind(format!("test-{}", ch_id.simple()))
|
||||
.bind(vec![0u8; 32])
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert open channel");
|
||||
ch_id
|
||||
}
|
||||
|
||||
/// Add a member to a channel.
|
||||
async fn add_member(pool: &sqlx::PgPool, community_id: Uuid, channel_id: Uuid, pubkey: &[u8]) {
|
||||
sqlx::query(
|
||||
"INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) \
|
||||
VALUES ($1, $2, $3, 'member', $4) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(channel_id)
|
||||
.bind(pubkey)
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("add member");
|
||||
}
|
||||
|
||||
/// Build a signed kind:5 delete event with one e-tag targeting `target_id_hex`.
|
||||
fn delete_event_e_tag(actor_keys: &Keys, target_id_hex: &str) -> nostr::Event {
|
||||
let tags = vec![Tag::parse(["e", target_id_hex]).unwrap()];
|
||||
EventBuilder::new(Kind::Custom(5), "")
|
||||
.tags(tags)
|
||||
.sign_with_keys(actor_keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Self-author can always delete their own message (no membership re-gate on delete).
|
||||
#[tokio::test]
|
||||
async fn delete_own_message_succeeds() {
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let Some(state) = test_state(pool.clone()).await else {
|
||||
return;
|
||||
};
|
||||
let tenant = seed_community(&pool).await;
|
||||
let community_uuid = *tenant.community().as_uuid();
|
||||
|
||||
let actor_keys = Keys::generate();
|
||||
let actor_bytes = actor_keys.public_key().to_bytes().to_vec();
|
||||
insert_user(&pool, community_uuid, &actor_bytes).await;
|
||||
|
||||
let ch_id = insert_private_channel(&pool, community_uuid).await;
|
||||
add_member(&pool, community_uuid, ch_id, &actor_bytes).await;
|
||||
|
||||
// Insert the target event authored by actor.
|
||||
let target_event = EventBuilder::new(Kind::Custom(9), "hello")
|
||||
.tags([Tag::parse(["h", &ch_id.to_string()]).unwrap()])
|
||||
.sign_with_keys(&actor_keys)
|
||||
.unwrap();
|
||||
let target_id_hex = target_event.id.to_hex();
|
||||
state
|
||||
.db
|
||||
.insert_event(tenant.community(), &target_event, Some(ch_id))
|
||||
.await
|
||||
.expect("insert target");
|
||||
|
||||
let delete = delete_event_e_tag(&actor_keys, &target_id_hex);
|
||||
validate_standard_deletion_event(&tenant, &delete, &state)
|
||||
.await
|
||||
.expect("self-author delete must succeed");
|
||||
}
|
||||
|
||||
/// Owning human can delete their agent's message when they are a channel member.
|
||||
#[tokio::test]
|
||||
async fn delete_owner_agent_message_as_member_succeeds() {
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let Some(state) = test_state(pool.clone()).await else {
|
||||
return;
|
||||
};
|
||||
let tenant = seed_community(&pool).await;
|
||||
let community_uuid = *tenant.community().as_uuid();
|
||||
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let owner_bytes = owner_keys.public_key().to_bytes().to_vec();
|
||||
let agent_bytes = agent_keys.public_key().to_bytes().to_vec();
|
||||
|
||||
insert_user(&pool, community_uuid, &owner_bytes).await;
|
||||
insert_agent_with_owner(&pool, community_uuid, &agent_bytes, &owner_bytes).await;
|
||||
|
||||
let ch_id = insert_private_channel(&pool, community_uuid).await;
|
||||
add_member(&pool, community_uuid, ch_id, &owner_bytes).await;
|
||||
|
||||
// Target event is authored by the agent.
|
||||
let target_event = EventBuilder::new(Kind::Custom(9), "agent says hi")
|
||||
.tags([Tag::parse(["h", &ch_id.to_string()]).unwrap()])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.unwrap();
|
||||
let target_id_hex = target_event.id.to_hex();
|
||||
state
|
||||
.db
|
||||
.insert_event(tenant.community(), &target_event, Some(ch_id))
|
||||
.await
|
||||
.expect("insert agent target");
|
||||
|
||||
// Delete is signed by the owner, not the agent.
|
||||
let delete = delete_event_e_tag(&owner_keys, &target_id_hex);
|
||||
validate_standard_deletion_event(&tenant, &delete, &state)
|
||||
.await
|
||||
.expect("owner delete of agent message must succeed");
|
||||
}
|
||||
|
||||
/// Non-owner, non-author cannot delete another agent's message.
|
||||
#[tokio::test]
|
||||
async fn delete_non_owner_agent_message_is_rejected() {
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let Some(state) = test_state(pool.clone()).await else {
|
||||
return;
|
||||
};
|
||||
let tenant = seed_community(&pool).await;
|
||||
let community_uuid = *tenant.community().as_uuid();
|
||||
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let attacker_keys = Keys::generate();
|
||||
let owner_bytes = owner_keys.public_key().to_bytes().to_vec();
|
||||
let agent_bytes = agent_keys.public_key().to_bytes().to_vec();
|
||||
let attacker_bytes = attacker_keys.public_key().to_bytes().to_vec();
|
||||
|
||||
insert_user(&pool, community_uuid, &owner_bytes).await;
|
||||
insert_agent_with_owner(&pool, community_uuid, &agent_bytes, &owner_bytes).await;
|
||||
insert_user(&pool, community_uuid, &attacker_bytes).await;
|
||||
|
||||
let ch_id = insert_private_channel(&pool, community_uuid).await;
|
||||
add_member(&pool, community_uuid, ch_id, &attacker_bytes).await;
|
||||
|
||||
let target_event = EventBuilder::new(Kind::Custom(9), "agent says hi")
|
||||
.tags([Tag::parse(["h", &ch_id.to_string()]).unwrap()])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.unwrap();
|
||||
let target_id_hex = target_event.id.to_hex();
|
||||
state
|
||||
.db
|
||||
.insert_event(tenant.community(), &target_event, Some(ch_id))
|
||||
.await
|
||||
.expect("insert agent target");
|
||||
|
||||
// Attacker (not the owner) tries to delete the agent's message.
|
||||
let delete = delete_event_e_tag(&attacker_keys, &target_id_hex);
|
||||
let err = validate_standard_deletion_event(&tenant, &delete, &state)
|
||||
.await
|
||||
.expect_err("non-owner must be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("must be event author"),
|
||||
"expected 'must be event author', got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Owner who has been removed from a private channel cannot delete their agent's messages.
|
||||
#[tokio::test]
|
||||
async fn delete_owner_agent_message_removed_from_private_channel_is_rejected() {
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let Some(state) = test_state(pool.clone()).await else {
|
||||
return;
|
||||
};
|
||||
let tenant = seed_community(&pool).await;
|
||||
let community_uuid = *tenant.community().as_uuid();
|
||||
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let owner_bytes = owner_keys.public_key().to_bytes().to_vec();
|
||||
let agent_bytes = agent_keys.public_key().to_bytes().to_vec();
|
||||
|
||||
insert_user(&pool, community_uuid, &owner_bytes).await;
|
||||
insert_agent_with_owner(&pool, community_uuid, &agent_bytes, &owner_bytes).await;
|
||||
// Owner is intentionally NOT added as a channel member.
|
||||
|
||||
let ch_id = insert_private_channel(&pool, community_uuid).await;
|
||||
|
||||
let target_event = EventBuilder::new(Kind::Custom(9), "agent says hi")
|
||||
.tags([Tag::parse(["h", &ch_id.to_string()]).unwrap()])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.unwrap();
|
||||
let target_id_hex = target_event.id.to_hex();
|
||||
state
|
||||
.db
|
||||
.insert_event(tenant.community(), &target_event, Some(ch_id))
|
||||
.await
|
||||
.expect("insert agent target");
|
||||
|
||||
let delete = delete_event_e_tag(&owner_keys, &target_id_hex);
|
||||
let err = validate_standard_deletion_event(&tenant, &delete, &state)
|
||||
.await
|
||||
.expect_err("removed owner must be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("restricted: not a channel member"),
|
||||
"expected 'restricted: not a channel member', got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Owner CAN delete their agent's message in an open channel even without being a member.
|
||||
#[tokio::test]
|
||||
async fn delete_owner_agent_message_in_open_channel_without_membership_succeeds() {
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let Some(state) = test_state(pool.clone()).await else {
|
||||
return;
|
||||
};
|
||||
let tenant = seed_community(&pool).await;
|
||||
let community_uuid = *tenant.community().as_uuid();
|
||||
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let owner_bytes = owner_keys.public_key().to_bytes().to_vec();
|
||||
let agent_bytes = agent_keys.public_key().to_bytes().to_vec();
|
||||
|
||||
insert_user(&pool, community_uuid, &owner_bytes).await;
|
||||
insert_agent_with_owner(&pool, community_uuid, &agent_bytes, &owner_bytes).await;
|
||||
// Owner has no membership in the open channel.
|
||||
|
||||
let ch_id = insert_open_channel(&pool, community_uuid).await;
|
||||
|
||||
let target_event = EventBuilder::new(Kind::Custom(9), "agent says hi")
|
||||
.tags([Tag::parse(["h", &ch_id.to_string()]).unwrap()])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.unwrap();
|
||||
let target_id_hex = target_event.id.to_hex();
|
||||
state
|
||||
.db
|
||||
.insert_event(tenant.community(), &target_event, Some(ch_id))
|
||||
.await
|
||||
.expect("insert agent target");
|
||||
|
||||
let delete = delete_event_e_tag(&owner_keys, &target_id_hex);
|
||||
validate_standard_deletion_event(&tenant, &delete, &state)
|
||||
.await
|
||||
.expect("owner delete in open channel without membership must succeed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useEffectEvent } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
channelMessagesKey,
|
||||
@@ -686,6 +687,9 @@ export function useDeleteMessageMutation(channel: Channel | null) {
|
||||
(current = []) => current.filter((message) => message.id !== eventId),
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to delete message.");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user