diff --git a/Cargo.lock b/Cargo.lock index 09108807c6..17b87db34d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1109,6 +1109,7 @@ dependencies = [ "anyhow", "base64", "buzz-core", + "buzz-sdk", "buzz-ws-client", "chrono", "futures-util", diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index e41d2717fc..057ea972f7 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -676,7 +676,8 @@ pub(crate) fn effective_message_author(event: &Event, relay_pubkey: &nostr::Publ event.pubkey.to_bytes().to_vec() } -/// Validate kind:40003 edit ownership — event.pubkey must match target's effective author. +/// Validate kind:40003 edit ownership — event.pubkey must match target's effective author, +/// or the actor must be the owning human of the agent that authored the target message. async fn validate_edit_ownership( community_id: CommunityId, event: &Event, @@ -723,8 +724,36 @@ async fn validate_edit_ownership( let author = effective_message_author(&target_event.event, &state.relay_keypair.public_key()); let actor = event.pubkey.to_bytes().to_vec(); - if author != actor { - return Err("must be event author to edit".to_string()); + if author == actor { + // Author editing their own message: re-gate on membership/open visibility so that + // a removed private-channel member cannot mutate old messages after access is revoked. + if let Some(ch_id) = target_event.channel_id { + let is_member = state + .is_member_cached(community_id, ch_id, &actor) + .await + .map_err(|e| format!("db error checking membership: {e}"))?; + if !is_member { + let is_open = state + .db + .get_channel(community_id, ch_id) + .await + .map(|ch| ch.visibility == "open") + .unwrap_or(false); + if !is_open { + return Err("restricted: not a channel member".to_string()); + } + } + } + } else { + // Allow the owning human to edit messages authored by their agent. + let is_owner = state + .db + .is_agent_owner(community_id, &author, &actor) + .await + .map_err(|e| format!("db error checking agent ownership: {e}"))?; + if !is_owner { + return Err("must be event author to edit".to_string()); + } } Ok(()) } @@ -1379,8 +1408,17 @@ async fn ingest_event_inner( if let Some(ch_id) = channel_id { // kind:9021 (join) doesn't require prior membership. // kind:9007 (create) — channel doesn't exist yet; creator becomes owner in step 16. - let skip_membership = - kind_u32 == KIND_NIP29_JOIN_REQUEST || kind_u32 == KIND_NIP29_CREATE_GROUP; + // kind:40003/9002/9005/9008 — per-kind validators are the authority; they + // individually enforce authorization and fail closed. Bypassing the generic + // member/open gate here lets the owning human act on private agent channels + // without being a member (OQ1 decision; see validate_edit_ownership / + // validate_admin_event for per-kind enforcement). + let skip_membership = kind_u32 == KIND_NIP29_JOIN_REQUEST + || kind_u32 == KIND_NIP29_CREATE_GROUP + || kind_u32 == KIND_STREAM_MESSAGE_EDIT + || kind_u32 == KIND_NIP29_EDIT_METADATA + || kind_u32 == KIND_NIP29_DELETE_EVENT + || kind_u32 == KIND_NIP29_DELETE_GROUP; if !skip_membership { // Spec AuthCheck (line 794): emit the verdict at the actual // call site. claimed_community comes from the event's h tag diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index f5c5d36931..ae3beefe97 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -216,6 +216,30 @@ pub async fn validate_standard_deletion_event( Ok(()) } +/// Returns `true` if `actor_bytes` is the NIP-OA owner of **any** active owner-role +/// member in `members`. Used by kind:9002 and kind:9008 to authorize the owning +/// human of a channel's agent-owner(s) even when the human is not a channel member. +/// +/// Checking all active owners (not just the first) is correct under co-ownership: +/// an agent may be promoted to owner later, or multiple agents may co-own a channel. +async fn actor_owns_any_owner_agent( + state: &Arc, + community_id: buzz_core::CommunityId, + members: &[buzz_db::channel::MemberRecord], + actor_bytes: &[u8], +) -> anyhow::Result { + for owner in members.iter().filter(|m| m.role == "owner") { + if state + .db + .is_agent_owner(community_id, &owner.pubkey, actor_bytes) + .await? + { + return Ok(true); + } + } + Ok(false) +} + /// Validate an admin kind event BEFORE storage. pub async fn validate_admin_event( tenant: &TenantContext, @@ -459,9 +483,24 @@ pub async fn validate_admin_event( let actor_member = members.iter().find(|m| m.pubkey == actor_bytes); match actor_member { Some(m) if m.role == "owner" || m.role == "admin" => Ok(()), - _ => Err(anyhow::anyhow!( - "actor not authorized for name/about/archived/visibility/ttl changes" - )), + _ => { + // Allow the owning human of any active owner-role agent in the + // channel, even when the human is not a channel member — + // diverges from kind:9001 intentionally. + if actor_owns_any_owner_agent( + state, + tenant.community(), + &members, + &actor_bytes, + ) + .await? + { + return Ok(()); + } + Err(anyhow::anyhow!( + "actor not authorized for name/about/archived/visibility/ttl changes" + )) + } } } else { // topic/purpose: any member @@ -516,26 +555,66 @@ pub async fn validate_admin_event( let author = effective_message_author(&target_event.event, &state.relay_keypair.public_key()); if author == actor_bytes { - return Ok(()); // Author can always delete their own messages + // Author deleting their own message: re-gate on membership/open visibility so that + // a removed private-channel member cannot mutate old messages after access is revoked. + let is_member = state + .is_member_cached(tenant.community(), channel_id, &actor_bytes) + .await?; + if is_member { + return Ok(()); + } + let is_open = state + .db + .get_channel(tenant.community(), channel_id) + .await + .map(|ch| ch.visibility == "open") + .unwrap_or(false); + if is_open { + return Ok(()); + } + // Not a member and channel is private — fall through to owner/admin/owner-of-agent check. } - // Not the author — must be owner/admin. + // Not the author, or author who is no longer a member of a private channel — + // must be owner/admin or the owning human of the message's agent-author. let members = state.db.get_members(tenant.community(), channel_id).await?; let actor_member = members.iter().find(|m| m.pubkey == actor_bytes); match actor_member { Some(m) if m.role == "owner" || m.role == "admin" => Ok(()), - _ => Err(anyhow::anyhow!( - "must be event author or channel owner/admin" - )), + _ => { + // Allow the owning human of the agent that authored the target message, + // even when the human is not a channel member. + if state + .db + .is_agent_owner(tenant.community(), &author, &actor_bytes) + .await? + { + Ok(()) + } else { + Err(anyhow::anyhow!( + "must be event author or channel owner/admin" + )) + } + } } } 9008 => { - // DELETE_GROUP: owner only + // DELETE_GROUP: owner only, or the owning human of the channel's agent-owner. let members = state.db.get_members(tenant.community(), channel_id).await?; let actor_member = members.iter().find(|m| m.pubkey == actor_bytes); match actor_member { Some(m) if m.role == "owner" => Ok(()), - _ => Err(anyhow::anyhow!("only owner can delete group")), + _ => { + // Allow the owning human of any active owner-role agent in the + // channel, even when the human is not a channel member — + // diverges from kind:9001 intentionally. + if actor_owns_any_owner_agent(state, tenant.community(), &members, &actor_bytes) + .await? + { + return Ok(()); + } + Err(anyhow::anyhow!("only owner can delete group")) + } } } 9022 => { diff --git a/crates/buzz-test-client/Cargo.toml b/crates/buzz-test-client/Cargo.toml index bba41127b3..7ce3e2bc31 100644 --- a/crates/buzz-test-client/Cargo.toml +++ b/crates/buzz-test-client/Cargo.toml @@ -35,6 +35,7 @@ rand = { workspace = true } sha2 = { workspace = true } chrono = { workspace = true } s3 = { version = "0.37", package = "rust-s3", default-features = false, features = ["tokio-rustls-tls", "fail-on-err", "tags"] } +buzz-sdk = { workspace = true } [[bin]] name = "buzz-test-cli" diff --git a/crates/buzz-test-client/src/lib.rs b/crates/buzz-test-client/src/lib.rs index 8cf3ec790f..89bff3020d 100644 --- a/crates/buzz-test-client/src/lib.rs +++ b/crates/buzz-test-client/src/lib.rs @@ -106,6 +106,18 @@ impl BuzzTestClient { Ok(()) } + /// Performs NIP-42 authentication with a NIP-OA `auth` tag, establishing + /// the agent→owner relationship in the relay's DB. Call this as the + /// *agent* connection; the tag must be signed by `owner_keys`. + pub async fn authenticate_with_nip_oa( + &mut self, + agent_keys: &Keys, + auth_tag: &Tag, + ) -> Result<(), TestClientError> { + self.inner.authenticate(agent_keys, Some(auth_tag)).await?; + Ok(()) + } + /// Sends a signed event to the relay and waits for the OK response. pub async fn send_event(&mut self, event: Event) -> Result { Ok(self.inner.send_event(event).await?) 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 new file mode 100644 index 0000000000..57a888e102 --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_human_edit_agent_content.rs @@ -0,0 +1,899 @@ +//! End-to-end tests for human owners editing/managing content authored by +//! their agents — all four authorization predicate sites: +//! +//! - kind:40003 message edit (`validate_edit_ownership`) +//! - kind:9005 DELETE_EVENT (`validate_admin_event` 9005 branch) +//! - kind:9002 EDIT_METADATA privileged-tag branch +//! - kind:9008 DELETE_GROUP +//! +//! The owner→agent relationship is established via NIP-OA: the agent +//! connects and authenticates with an `auth` tag signed by the owner. +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! cargo test --test e2e_human_edit_agent_content -- --ignored +//! ``` + +use buzz_sdk::nip_oa; +use buzz_test_client::BuzzTestClient; +use nostr::{EventBuilder, Keys, Kind, Tag}; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn relay_http_url() -> String { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() +} + +/// Create a fresh channel owned by `owner_keys`, return the channel UUID string. +async fn create_agent_owned_channel(agent_keys: &Keys) -> String { + let http = reqwest::Client::new(); + let channel_uuid = uuid::Uuid::new_v4(); + + let event = EventBuilder::new(Kind::Custom(9007), "") + .tags(vec![ + Tag::parse(["h", &channel_uuid.to_string()]).unwrap(), + Tag::parse(["name", &format!("haec-test-{}", channel_uuid.simple())]).unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", "open"]).unwrap(), + ]) + .sign_with_keys(agent_keys) + .unwrap(); + + let resp = http + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", &agent_keys.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).unwrap()) + .send() + .await + .expect("submit create-channel event"); + assert!( + resp.status().is_success(), + "channel creation failed: {}", + resp.status() + ); + let body: serde_json::Value = resp.json().await.unwrap(); + assert!( + body["accepted"].as_bool().unwrap_or(false), + "channel creation not accepted: {body}" + ); + channel_uuid.to_string() +} + +/// Build a NIP-OA auth tag for `agent_keys` signed by `owner_keys`. +fn make_nip_oa_auth_tag(owner_keys: &Keys, agent_keys: &Keys) -> Tag { + let tag_json = nip_oa::compute_auth_tag(owner_keys, &agent_keys.public_key(), "kind=9") + .expect("compute_auth_tag"); + nip_oa::parse_auth_tag(&tag_json).expect("parse_auth_tag") +} + +/// Connect `agent_keys` to the relay with NIP-OA, establishing owner→agent in the DB. +/// Returns the connected (authenticated) client for the agent. +async fn connect_agent_with_owner(agent_keys: &Keys, owner_keys: &Keys) -> BuzzTestClient { + let url = relay_url(); + let auth_tag = make_nip_oa_auth_tag(owner_keys, agent_keys); + let mut client = BuzzTestClient::connect_unauthenticated(&url) + .await + .expect("connect agent unauthenticated"); + client + .authenticate_with_nip_oa(agent_keys, &auth_tag) + .await + .expect("NIP-OA auth"); + client +} + +// ─── kind:40003 message edit ─────────────────────────────────────────────── + +/// Owner can edit a message authored by their agent via kind:40003. +#[tokio::test] +#[ignore] +async fn test_owner_can_edit_agent_message() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let channel_id = create_agent_owned_channel(&agent_keys).await; + + // Establish NIP-OA ownership in DB. + let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await; + + // Agent sends a message. + 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; + + // Owner sends a kind:40003 edit event targeting the agent's message. + let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys) + .await + .expect("connect owner"); + + let edit_event = EventBuilder::new(Kind::Custom(40003), "corrected content") + .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(edit_event) + .await + .expect("send edit"); + assert!( + ok.accepted, + "owner edit of agent message rejected: {}", + ok.message + ); + + agent_client.disconnect().await.ok(); + owner_client.disconnect().await.ok(); +} + +/// An unrelated third party cannot edit an agent's message. +#[tokio::test] +#[ignore] +async fn test_third_party_cannot_edit_agent_message() { + 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 edit_event = EventBuilder::new(Kind::Custom(40003), "malicious edit") + .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(edit_event) + .await + .expect("send edit attempt"); + assert!( + !ok.accepted, + "third party should NOT be able to edit agent message, but was accepted" + ); + + agent_client.disconnect().await.ok(); + third_party_client.disconnect().await.ok(); +} + +/// The agent itself can still edit its own message (self-edit unchanged). +#[tokio::test] +#[ignore] +async fn test_agent_can_self_edit_message() { + 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 edit_event = EventBuilder::new(Kind::Custom(40003), "agent self-edit") + .tags(vec![ + Tag::parse(["e", &msg_event_id]).unwrap(), + Tag::parse(["h", &channel_id]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .unwrap(); + + let ok = agent_client + .send_event(edit_event) + .await + .expect("send edit"); + assert!(ok.accepted, "agent self-edit rejected: {}", ok.message); + + agent_client.disconnect().await.ok(); +} + +// ─── kind:9005 DELETE_EVENT ───────────────────────────────────────────────── + +/// Owner can delete a message authored by their agent via kind:9005. +#[tokio::test] +#[ignore] +async fn test_owner_can_delete_agent_message() { + 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(9005), "") + .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 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. +#[tokio::test] +#[ignore] +async fn test_third_party_cannot_delete_agent_message() { + 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(9005), "") + .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 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, +/// even when the owner is not a channel member. +#[tokio::test] +#[ignore] +async fn test_owner_can_edit_agent_channel_metadata() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + + // Agent creates the channel (agent is the channel owner-member). + let channel_id = create_agent_owned_channel(&agent_keys).await; + + // Establish NIP-OA ownership. + let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await; + agent_client.disconnect().await.ok(); + + // Owner sends kind:9002 to rename the channel — owner is NOT a member. + let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys) + .await + .expect("connect owner"); + + let edit_event = EventBuilder::new(Kind::Custom(9002), "") + .tags(vec![ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["name", "owner-renamed-channel"]).unwrap(), + ]) + .sign_with_keys(&owner_keys) + .unwrap(); + + let ok = owner_client + .send_event(edit_event) + .await + .expect("send edit metadata"); + assert!( + ok.accepted, + "owner edit of agent channel metadata rejected: {}", + ok.message + ); + + owner_client.disconnect().await.ok(); +} + +/// Owner can archive an agent's channel via kind:9002. +#[tokio::test] +#[ignore] +async fn test_owner_can_archive_agent_channel() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let channel_id = create_agent_owned_channel(&agent_keys).await; + + let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await; + agent_client.disconnect().await.ok(); + + let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys) + .await + .expect("connect owner"); + + let archive_event = EventBuilder::new(Kind::Custom(9002), "") + .tags(vec![ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["archived", "true"]).unwrap(), + ]) + .sign_with_keys(&owner_keys) + .unwrap(); + + let ok = owner_client + .send_event(archive_event) + .await + .expect("send archive"); + assert!( + ok.accepted, + "owner archive of agent channel rejected: {}", + ok.message + ); + + owner_client.disconnect().await.ok(); +} + +/// Unrelated third party cannot edit metadata of an agent's channel. +#[tokio::test] +#[ignore] +async fn test_third_party_cannot_edit_agent_channel_metadata() { + 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 agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await; + agent_client.disconnect().await.ok(); + + let mut third_party_client = BuzzTestClient::connect(&relay_url(), &third_party_keys) + .await + .expect("connect third party"); + + let edit_event = EventBuilder::new(Kind::Custom(9002), "") + .tags(vec![ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["name", "hijacked-name"]).unwrap(), + ]) + .sign_with_keys(&third_party_keys) + .unwrap(); + + let ok = third_party_client + .send_event(edit_event) + .await + .expect("send edit attempt"); + assert!( + !ok.accepted, + "third party should NOT be able to edit agent channel metadata, but was accepted" + ); + + third_party_client.disconnect().await.ok(); +} + +// ─── kind:9008 DELETE_GROUP ───────────────────────────────────────────────── + +/// Owner can delete a channel owned by their agent via kind:9008, +/// even when the owner is not a channel member. +#[tokio::test] +#[ignore] +async fn test_owner_can_delete_agent_channel() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let channel_id = create_agent_owned_channel(&agent_keys).await; + + // Establish NIP-OA ownership. + let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await; + agent_client.disconnect().await.ok(); + + // Owner sends kind:9008 to delete the channel — owner is NOT a member. + let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys) + .await + .expect("connect owner"); + + let delete_event = EventBuilder::new(Kind::Custom(9008), "") + .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&owner_keys) + .unwrap(); + + let ok = owner_client + .send_event(delete_event) + .await + .expect("send delete group"); + assert!( + ok.accepted, + "owner delete of agent channel rejected: {}", + ok.message + ); + + owner_client.disconnect().await.ok(); +} + +/// Unrelated third party cannot delete an agent's channel. +#[tokio::test] +#[ignore] +async fn test_third_party_cannot_delete_agent_channel() { + 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 agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await; + agent_client.disconnect().await.ok(); + + let mut third_party_client = BuzzTestClient::connect(&relay_url(), &third_party_keys) + .await + .expect("connect third party"); + + let delete_event = EventBuilder::new(Kind::Custom(9008), "") + .tags(vec![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 delete agent channel, but was accepted" + ); + + third_party_client.disconnect().await.ok(); +} + +/// Create a fresh **private** channel owned by `agent_keys`, return the channel UUID string. +async fn create_private_agent_owned_channel(agent_keys: &Keys) -> String { + let http = reqwest::Client::new(); + let channel_uuid = uuid::Uuid::new_v4(); + + let event = EventBuilder::new(Kind::Custom(9007), "") + .tags(vec![ + Tag::parse(["h", &channel_uuid.to_string()]).unwrap(), + Tag::parse([ + "name", + &format!("haec-private-test-{}", channel_uuid.simple()), + ]) + .unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", "private"]).unwrap(), + ]) + .sign_with_keys(agent_keys) + .unwrap(); + + let resp = http + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", &agent_keys.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).unwrap()) + .send() + .await + .expect("submit create-private-channel event"); + assert!( + resp.status().is_success(), + "private channel creation failed: {}", + resp.status() + ); + let body: serde_json::Value = resp.json().await.unwrap(); + assert!( + body["accepted"].as_bool().unwrap_or(false), + "private channel creation not accepted: {body}" + ); + channel_uuid.to_string() +} + +// ─── Private-channel coverage (Fix 1 regression guard) ────────────────────── +// +// These tests verify that the membership-gate bypass works on private channels — +// i.e., a non-member owning human can act on private agent-owned content. +// Previously all four predicates were unreachable for private channels because +// check_channel_membership rejected non-members before the per-kind validators ran. + +/// Owner can edit a message in a private agent-owned channel (non-member owner allowed). +#[tokio::test] +#[ignore] +async fn test_owner_can_edit_agent_message_in_private_channel() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let channel_id = create_private_agent_owned_channel(&agent_keys).await; + + // Establish NIP-OA ownership; agent sends a message. + let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await; + let content = format!("private-agent-msg-{}", uuid::Uuid::new_v4()); + let ok = agent_client + .send_text_message(&agent_keys, &channel_id, &content, 9) + .await + .expect("agent send message to private channel"); + assert!( + ok.accepted, + "agent message to private channel rejected: {}", + ok.message + ); + let msg_event_id = ok.event_id; + agent_client.disconnect().await.ok(); + + // Owner (not a channel member) edits the agent's message. + let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys) + .await + .expect("connect owner"); + let edit_event = EventBuilder::new(Kind::Custom(40003), "edited content") + .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(edit_event) + .await + .expect("send edit"); + assert!( + ok.accepted, + "owner edit of agent message in private channel rejected (membership gate not bypassed): {}", + ok.message + ); + owner_client.disconnect().await.ok(); +} + +/// Owner can delete a message in a private agent-owned channel (non-member owner allowed). +#[tokio::test] +#[ignore] +async fn test_owner_can_delete_agent_message_in_private_channel() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let channel_id = create_private_agent_owned_channel(&agent_keys).await; + + let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await; + let content = format!("private-agent-msg-{}", uuid::Uuid::new_v4()); + let ok = agent_client + .send_text_message(&agent_keys, &channel_id, &content, 9) + .await + .expect("agent send message to private channel"); + assert!( + ok.accepted, + "agent message to private channel rejected: {}", + ok.message + ); + let msg_event_id = ok.event_id; + agent_client.disconnect().await.ok(); + + let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys) + .await + .expect("connect owner"); + let delete_event = EventBuilder::new(Kind::Custom(9005), "") + .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 delete of agent message in private channel rejected (membership gate not bypassed): {}", + ok.message + ); + owner_client.disconnect().await.ok(); +} + +/// Owner can edit metadata of a private agent-owned channel (non-member owner allowed). +#[tokio::test] +#[ignore] +async fn test_owner_can_edit_metadata_of_private_agent_channel() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let channel_id = create_private_agent_owned_channel(&agent_keys).await; + + let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await; + agent_client.disconnect().await.ok(); + + let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys) + .await + .expect("connect owner"); + let edit_event = EventBuilder::new(Kind::Custom(9002), "") + .tags(vec![ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["name", "owner-renamed-private-channel"]).unwrap(), + ]) + .sign_with_keys(&owner_keys) + .unwrap(); + let ok = owner_client + .send_event(edit_event) + .await + .expect("send edit metadata"); + assert!( + ok.accepted, + "owner edit of private agent channel metadata rejected (membership gate not bypassed): {}", + ok.message + ); + owner_client.disconnect().await.ok(); +} + +/// Owner can delete a private agent-owned channel (non-member owner allowed). +#[tokio::test] +#[ignore] +async fn test_owner_can_delete_private_agent_channel() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let channel_id = create_private_agent_owned_channel(&agent_keys).await; + + let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await; + agent_client.disconnect().await.ok(); + + let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys) + .await + .expect("connect owner"); + let delete_event = EventBuilder::new(Kind::Custom(9008), "") + .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&owner_keys) + .unwrap(); + let ok = owner_client + .send_event(delete_event) + .await + .expect("send delete group"); + assert!( + ok.accepted, + "owner delete of private agent channel rejected (membership gate not bypassed): {}", + ok.message + ); + owner_client.disconnect().await.ok(); +} + +/// Agent itself can still delete its own channel (self-delete unchanged). +#[tokio::test] +#[ignore] +async fn test_agent_can_self_delete_channel() { + 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 delete_event = EventBuilder::new(Kind::Custom(9008), "") + .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&agent_keys) + .unwrap(); + + let ok = agent_client + .send_event(delete_event) + .await + .expect("send self-delete group"); + assert!( + ok.accepted, + "agent self-delete of own channel rejected: {}", + ok.message + ); + + agent_client.disconnect().await.ok(); +} + +// ─── Removed-author rejection tests (Option A regression guard) ───────────── +// +// These tests verify that removing a user from a private channel revokes their +// ability to edit or delete their own historical messages. Before Option A, +// adding 40003/9005 to skip_membership also widened the self-author fast-path, +// allowing removed users to mutate private-channel history. + +/// A user removed from a private channel CANNOT edit their own old messages. +#[tokio::test] +#[ignore] +async fn test_removed_author_cannot_edit_own_message_in_private_channel() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let victim_keys = Keys::generate(); + let channel_id = create_private_agent_owned_channel(&agent_keys).await; + + // Establish NIP-OA ownership; agent is now in the channel. + let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await; + + // Agent adds victim to the private channel via kind:9000 (PUT_USER). + let add_event = EventBuilder::new(Kind::Custom(9000), "") + .tags(vec![ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["p", &victim_keys.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .unwrap(); + let ok = agent_client + .send_event(add_event) + .await + .expect("send PUT_USER"); + assert!( + ok.accepted, + "agent failed to add victim to private channel: {}", + ok.message + ); + + // Victim connects and sends a message while still a member. + let mut victim_client = BuzzTestClient::connect(&relay_url(), &victim_keys) + .await + .expect("connect victim"); + let content = format!("victim-msg-{}", uuid::Uuid::new_v4()); + let ok = victim_client + .send_text_message(&victim_keys, &channel_id, &content, 9) + .await + .expect("victim send message"); + assert!( + ok.accepted, + "victim message rejected while still a member: {}", + ok.message + ); + let msg_event_id = ok.event_id; + + // Agent removes victim from the channel via kind:9001 (REMOVE_USER). + let remove_event = EventBuilder::new(Kind::Custom(9001), "") + .tags(vec![ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["p", &victim_keys.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .unwrap(); + let ok = agent_client + .send_event(remove_event) + .await + .expect("send REMOVE_USER"); + assert!( + ok.accepted, + "agent failed to remove victim from private channel: {}", + ok.message + ); + agent_client.disconnect().await.ok(); + + // Victim (now removed) attempts to edit their old message — must be rejected. + let edit_event = EventBuilder::new(Kind::Custom(40003), "edited content after removal") + .tags(vec![ + Tag::parse(["e", &msg_event_id]).unwrap(), + Tag::parse(["h", &channel_id]).unwrap(), + ]) + .sign_with_keys(&victim_keys) + .unwrap(); + let ok = victim_client + .send_event(edit_event) + .await + .expect("send edit attempt"); + assert!( + !ok.accepted, + "removed author should NOT be able to edit old message in private channel, but was accepted" + ); + victim_client.disconnect().await.ok(); +} + +/// A user removed from a private channel CANNOT delete their own old messages. +#[tokio::test] +#[ignore] +async fn test_removed_author_cannot_delete_own_message_in_private_channel() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let victim_keys = Keys::generate(); + let channel_id = create_private_agent_owned_channel(&agent_keys).await; + + // Establish NIP-OA ownership; agent is now in the channel. + let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await; + + // Agent adds victim to the private channel via kind:9000 (PUT_USER). + let add_event = EventBuilder::new(Kind::Custom(9000), "") + .tags(vec![ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["p", &victim_keys.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .unwrap(); + let ok = agent_client + .send_event(add_event) + .await + .expect("send PUT_USER"); + assert!( + ok.accepted, + "agent failed to add victim to private channel: {}", + ok.message + ); + + // Victim connects and sends a message while still a member. + let mut victim_client = BuzzTestClient::connect(&relay_url(), &victim_keys) + .await + .expect("connect victim"); + let content = format!("victim-msg-{}", uuid::Uuid::new_v4()); + let ok = victim_client + .send_text_message(&victim_keys, &channel_id, &content, 9) + .await + .expect("victim send message"); + assert!( + ok.accepted, + "victim message rejected while still a member: {}", + ok.message + ); + let msg_event_id = ok.event_id; + + // Agent removes victim from the channel via kind:9001 (REMOVE_USER). + let remove_event = EventBuilder::new(Kind::Custom(9001), "") + .tags(vec![ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["p", &victim_keys.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .unwrap(); + let ok = agent_client + .send_event(remove_event) + .await + .expect("send REMOVE_USER"); + assert!( + ok.accepted, + "agent failed to remove victim from private channel: {}", + ok.message + ); + agent_client.disconnect().await.ok(); + + // Victim (now removed) attempts to delete their old message — must be rejected. + let delete_event = EventBuilder::new(Kind::Custom(9005), "") + .tags(vec![ + Tag::parse(["e", &msg_event_id]).unwrap(), + Tag::parse(["h", &channel_id]).unwrap(), + ]) + .sign_with_keys(&victim_keys) + .unwrap(); + let ok = victim_client + .send_event(delete_event) + .await + .expect("send delete attempt"); + assert!( + !ok.accepted, + "removed author should NOT be able to delete old message in private channel, but was accepted" + ); + victim_client.disconnect().await.ok(); +} diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 0c827cc95f..fecfc7103c 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -58,6 +58,7 @@ export default defineConfig({ "**/overscroll-boundary.spec.ts", "**/cold-switch-longtask.perf.ts", "**/timeline-no-shift.spec.ts", + "**/human-edit-agent-content.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src/features/channels/ui/ChannelManagementModerationActions.tsx b/desktop/src/features/channels/ui/ChannelManagementModerationActions.tsx index 7a14546ada..c5e192fd2a 100644 --- a/desktop/src/features/channels/ui/ChannelManagementModerationActions.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementModerationActions.tsx @@ -29,7 +29,7 @@ type ChannelManagementModerationActionsProps = { isArchived: boolean; isDark: boolean; isDeleteDialogOpen: boolean; - isOwner: boolean; + canDeleteChannel: boolean; resolvedChannelName: string; unarchiveChannelMutation: ChannelMutation; }; @@ -43,7 +43,7 @@ export function ChannelManagementModerationActions({ isArchived, isDark, isDeleteDialogOpen, - isOwner, + canDeleteChannel, resolvedChannelName, unarchiveChannelMutation, }: ChannelManagementModerationActionsProps) { @@ -104,7 +104,7 @@ export function ChannelManagementModerationActions({ )} - {isOwner ? ( + {canDeleteChannel ? ( member.pubkey === currentPubkey) ?? null; const hasResolvedMembership = membersQuery.data !== undefined; - const isOwner = selfMember?.role === "owner"; + + // Collect owner-role member pubkeys to look up their NIP-OA ownerPubkey. + // This is what surfaces the "you own the agent that owns this channel" path. + const ownerMemberPubkeys = React.useMemo( + () => + members + .filter((m) => m.role === "owner" && m.pubkey !== currentPubkey) + .map((m) => m.pubkey), + [members, currentPubkey], + ); + const ownerProfilesQuery = useUsersBatchQuery(ownerMemberPubkeys, { + enabled: open && ownerMemberPubkeys.length > 0, + }); + // True when an owner-role member of this channel is an agent owned by the + // current user — mirrors the relay's is_agent_owner gate. + const canManageOwnedAgentChannel = React.useMemo(() => { + if (!currentPubkey || !ownerProfilesQuery.data) return false; + return ownerMemberPubkeys.some((pubkey) => + ownsAuthorAgent( + ownerProfilesQuery.data?.profiles[normalizePubkey(pubkey)], + currentPubkey, + ), + ); + }, [currentPubkey, ownerMemberPubkeys, ownerProfilesQuery.data]); + + const isSelfOwner = selfMember?.role === "owner"; + // Capability: may delete this channel (self-owner OR owns the agent-owner). + const canDeleteChannel = isSelfOwner || canManageOwnedAgentChannel; const canManageChannel = - selfMember?.role === "owner" || selfMember?.role === "admin"; + selfMember?.role === "owner" || + selfMember?.role === "admin" || + canManageOwnedAgentChannel; const canEditNarrative = canManageChannel && selfMember !== null && detail?.channelType !== "dm"; const isArchived = @@ -353,7 +385,7 @@ export function ChannelManagementSheet({ isArchived={isArchived} isDark={isDark} isDeleteDialogOpen={isDeleteDialogOpen} - isOwner={isOwner} + canDeleteChannel={canDeleteChannel} mode={auxiliaryPanelMode} transparentChrome={transparentChrome} joinChannelMutation={joinChannelMutation} @@ -399,7 +431,7 @@ export function ChannelManagementSheet({ isArchived={isArchived} isDark={isDark} isDeleteDialogOpen={isDeleteDialogOpen} - isOwner={isOwner} + canDeleteChannel={canDeleteChannel} mode={auxiliaryPanelMode} transparentChrome={transparentChrome} joinChannelMutation={joinChannelMutation} @@ -635,7 +667,7 @@ type ChannelManagementPanelContentProps = { isArchived: boolean; isDark: boolean; isDeleteDialogOpen: boolean; - isOwner: boolean; + canDeleteChannel: boolean; // true when caller may delete the channel mode: AuxiliaryPanelMode; transparentChrome?: boolean; joinChannelMutation: ChannelMutation; @@ -667,7 +699,7 @@ function ChannelManagementPanelContent({ isArchived, isDark, isDeleteDialogOpen, - isOwner, + canDeleteChannel, mode, transparentChrome = false, joinChannelMutation, @@ -933,7 +965,7 @@ function ChannelManagementPanelContent({ isArchived={isArchived} isDark={isDark} isDeleteDialogOpen={isDeleteDialogOpen} - isOwner={isOwner} + canDeleteChannel={canDeleteChannel} resolvedChannelName={resolvedChannel.name} unarchiveChannelMutation={unarchiveChannelMutation} /> diff --git a/desktop/src/features/messages/lib/canManageMessage.ts b/desktop/src/features/messages/lib/canManageMessage.ts new file mode 100644 index 0000000000..4b44ec7186 --- /dev/null +++ b/desktop/src/features/messages/lib/canManageMessage.ts @@ -0,0 +1,30 @@ +import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import type { TimelineMessage } from "@/features/messages/types"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { ownsAuthorAgent } from "@/features/profile/lib/identity"; + +/** + * Returns true when the current user may edit or delete `message`. + * + * Two paths grant permission — mirroring the relay's authz: + * 1. Self-author: the current user pubkey matches the message pubkey. + * 2. Owner-of-agent: the message author's profile carries an `ownerPubkey` + * (NIP-OA owner record) equal to the current user's pubkey. + * + * Huddle-started messages are immutable regardless of authorship. + */ +export function canManageMessageForCurrentUser( + message: TimelineMessage, + currentPubkey: string | undefined, + profiles: UserProfileLookup | undefined, +): boolean { + if (message.kind === KIND_HUDDLE_STARTED) return false; + if (!currentPubkey || !message.pubkey) return false; + if (normalizePubkey(message.pubkey) === normalizePubkey(currentPubkey)) + return true; + return ownsAuthorAgent( + profiles?.[normalizePubkey(message.pubkey)], + currentPubkey, + ); +} diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 15d3ef3680..a146c76cea 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -7,6 +7,7 @@ import { type MainTimelineEntry, } from "@/features/messages/lib/threadPanel"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Channel } from "@/shared/api/types"; @@ -103,17 +104,6 @@ type MessageThreadPanelSkeletonProps = { transparentChrome?: boolean; }; -function canManageMessage( - message: TimelineMessage, - currentPubkey: string | undefined, -): boolean { - return Boolean( - currentPubkey && - message.pubkey && - currentPubkey.toLowerCase() === message.pubkey.toLowerCase(), - ); -} - function hasLaterVisibleSibling( entries: readonly MainTimelineEntry[], entryIndex: number, @@ -583,12 +573,22 @@ export function MessageThreadPanel({ layoutVariant="thread-reply" message={threadHead} onDelete={ - onDelete && canManageMessage(threadHead, currentPubkey) + onDelete && + canManageMessageForCurrentUser( + threadHead, + currentPubkey, + profiles, + ) ? onDelete : undefined } onEdit={ - onEdit && canManageMessage(threadHead, currentPubkey) + onEdit && + canManageMessageForCurrentUser( + threadHead, + currentPubkey, + profiles, + ) ? onEdit : undefined } @@ -717,13 +717,21 @@ export function MessageThreadPanel({ } onDelete={ onDelete && - canManageMessage(entry.message, currentPubkey) + canManageMessageForCurrentUser( + entry.message, + currentPubkey, + profiles, + ) ? onDelete : undefined } onEdit={ onEdit && - canManageMessage(entry.message, currentPubkey) + canManageMessageForCurrentUser( + entry.message, + currentPubkey, + profiles, + ) ? onEdit : undefined } diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 22ebe8cb4d..5819368771 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -14,9 +14,9 @@ import { hasVideoAttachment, } from "@/features/messages/lib/videoReviewContext"; import type { TimelineMessage } from "@/features/messages/types"; +import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; -import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; import { DayDivider } from "./DayDivider"; import { MessageRow } from "./MessageRow"; @@ -335,21 +335,13 @@ function MessageRowItem({ videoReviewContext, }: MessageRowItemProps) { const { message, summary } = entry; - const isMutableMessage = message.kind !== KIND_HUDDLE_STARTED; - const canDelete = - isMutableMessage && - onDelete && - currentPubkey && - message.pubkey === currentPubkey - ? onDelete - : undefined; - const canEdit = - isMutableMessage && - onEdit && - currentPubkey && - message.pubkey === currentPubkey - ? onEdit - : undefined; + const canManage = canManageMessageForCurrentUser( + message, + currentPubkey, + profiles, + ); + const canDelete = canManage && onDelete ? onDelete : undefined; + const canEdit = canManage && onEdit ? onEdit : undefined; if (summary && onReply) { const isHighlighted = message.id === highlightedMessageId; diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index 491883a0af..3093cebf61 100644 --- a/desktop/src/features/profile/lib/identity.ts +++ b/desktop/src/features/profile/lib/identity.ts @@ -85,6 +85,23 @@ export function resolveUserLabel(input: { return truncatePubkey(pubkey); } +/** + * Returns true when the current user owns the agent that authored a message. + * Mirrors the relay's `is_agent_owner` gate: ownership is determined by the + * NIP-OA `ownerPubkey` field on the author's profile, NOT by the local + * managed-agents list (which can diverge from server-side ownership). + */ +export function ownsAuthorAgent( + profile: { ownerPubkey: string | null } | undefined, + currentPubkey: string | undefined, +): boolean { + return ( + !!currentPubkey && + !!profile?.ownerPubkey && + normalizePubkey(profile.ownerPubkey) === normalizePubkey(currentPubkey) + ); +} + export function resolveUserSecondaryLabel(input: { pubkey: string; profiles?: UserProfileLookup; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index bd590becbb..e4c6b99338 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -6235,6 +6235,23 @@ async function handleSendManagedAgentChannelMessage( }; } +/** + * Mock the `delete_message` Tauri command. Removes the event from the + * in-memory mock store so the query-cache invalidation in + * `useDeleteMessageMutation.onSuccess` (which filters by eventId) finds + * nothing to keep, and the row disappears from the timeline. + */ +function handleDeleteMessage(args: { + channelId: string; + eventId: string; +}): void { + const history = mockMessages.get(args.channelId); + if (history) { + const index = history.findIndex((ev) => ev.id === args.eventId); + if (index !== -1) history.splice(index, 1); + } +} + /** * Mock the `edit_message` Tauri command. Mirrors the real Rust command * (`build_message_edit`): emit a kind:40003 edit event carrying `["e", target]` @@ -7478,6 +7495,11 @@ export function maybeInstallE2eTauriMocks() { payload as Parameters[0], activeConfig, ); + case "delete_message": + handleDeleteMessage( + payload as Parameters[0], + ); + return null; case "edit_message": return handleEditMessage( payload as Parameters[0], diff --git a/desktop/tests/e2e/human-edit-agent-content.spec.ts b/desktop/tests/e2e/human-edit-agent-content.spec.ts new file mode 100644 index 0000000000..93bc136217 --- /dev/null +++ b/desktop/tests/e2e/human-edit-agent-content.spec.ts @@ -0,0 +1,284 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +// Fixed pubkey for the owned managed agent seeded in these tests. +// Must not collide with any existing e2eBridge constant. +const OWNED_AGENT_PUBKEY = + "a0b1c2d3e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e"; + +// #random is owned by alice; the mock identity is a plain member. +// This is the isolation fixture for the canManageOwnedAgentChannel path — +// selfMember.role is "member" not "owner", so without the new gate the +// Edit button would not appear. +const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d"; + +// Mock-bridge helper: wait for the bridge to initialise, then invoke a command. +async function invoke( + page: import("@playwright/test").Page, + command: string, + payload?: Record, +): Promise { + await page.waitForFunction(() => + Boolean( + ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__, + ), + ); + return page.evaluate( + async ({ cmd, p }) => { + const win = window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload?: Record, + ) => Promise; + }; + const fn = win.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!fn) throw new Error("Mock bridge unavailable"); + return fn(cmd, p); + }, + { cmd: command, p: payload }, + ); +} + +// Open the more-actions menu for a message row and wait for the menu to mount. +async function openMoreActionsMenu( + page: import("@playwright/test").Page, + messageId: string, +) { + const row = page.locator(`[data-message-id="${messageId}"]`); + await row.hover(); + await page.getByTestId(`more-actions-${messageId}`).click(); + // Wait for dropdown content to mount — any menu item signals it's open. + await expect(page.locator('[role="menuitem"]').first()).toBeVisible({ + timeout: 5_000, + }); +} + +test.beforeEach(async ({ page }) => { + await installMockBridge(page, { + managedAgents: [ + { + // OwnedBot: a managed agent owned by the mock identity. + // The bridge automatically sets owner_pubkey = MOCK_IDENTITY_PUBKEY + // in mockProfiles when a managed agent is seeded. + pubkey: OWNED_AGENT_PUBKEY, + name: "OwnedBot", + personaId: "builtin:fizz", + status: "running", + // Seed into #agents so the bridge seeds a message from this agent. + channelNames: ["agents"], + }, + ], + }); +}); + +// ─── Message gate ───────────────────────────────────────────────────────────── + +test("owner can edit their owned agent's message", async ({ page }) => { + // The bridge seeds a message from each managed agent in its channels: + // id: `mock-agents-managed-${pubkey.slice(0, 8)}` + const messageId = `mock-agents-managed-${OWNED_AGENT_PUBKEY.slice(0, 8)}`; + const editedContent = `Edited by owner ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + + // Wait for the agent's seeded message to appear. + const agentRow = page.locator(`[data-message-id="${messageId}"]`); + await expect(agentRow).toBeVisible({ timeout: 10_000 }); + + // Open the more-actions menu and click Edit message. + await openMoreActionsMenu(page, messageId); + await page.getByTestId(`edit-message-${messageId}`).click(); + + // Edit banner must appear confirming edit mode is active. + await expect(page.getByTestId("edit-target")).toBeVisible({ timeout: 5_000 }); + + // Clear the composer and type the new content, then submit. + const input = page.getByTestId("message-input"); + await input.clear(); + await input.fill(editedContent); + await input.press("Enter"); + + // Edit mode must exit (banner gone) and the updated content must render. + await expect(page.getByTestId("edit-target")).toBeHidden(); + await expect(page.getByTestId("message-timeline")).toContainText( + editedContent, + ); +}); + +test("owner can delete their owned agent's message", async ({ page }) => { + const messageId = `mock-agents-managed-${OWNED_AGENT_PUBKEY.slice(0, 8)}`; + + await page.goto("/"); + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + + const agentRow = page.locator(`[data-message-id="${messageId}"]`); + await expect(agentRow).toBeVisible({ timeout: 10_000 }); + + // Open the more-actions menu and click Delete message. + await openMoreActionsMenu(page, messageId); + await page.getByTestId(`delete-message-${messageId}`).click(); + + // Confirm the deletion in the AlertDialog. + await expect(page.getByRole("alertdialog")).toBeVisible({ timeout: 5_000 }); + // The destructive confirm button inside the dialog. + await page + .getByRole("alertdialog") + .getByRole("button", { name: "Delete" }) + .click(); + + // The message row must be removed from the timeline. + await expect(agentRow).toBeHidden({ timeout: 5_000 }); +}); + +test("owner does NOT see Edit or Delete for an unowned agent's message", async ({ + page, +}) => { + // "mock-agents-charlie" is seeded in #agents for CHARLIE_PUBKEY. + // Charlie is in mockAgentPubkeys but ownerPubkey is NOT the mock identity. + const charlieMessageId = "mock-agents-charlie"; + + await page.goto("/"); + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + + const charlieRow = page.locator(`[data-message-id="${charlieMessageId}"]`); + await expect(charlieRow).toBeVisible({ timeout: 10_000 }); + + // Open the more-actions menu — it must open without Edit or Delete items. + await charlieRow.hover(); + await page.getByTestId(`more-actions-${charlieMessageId}`).click(); + await expect(page.locator('[role="menuitem"]').first()).toBeVisible({ + timeout: 5_000, + }); + + await expect( + page.getByTestId(`edit-message-${charlieMessageId}`), + ).toHaveCount(0); + await expect( + page.getByTestId(`delete-message-${charlieMessageId}`), + ).toHaveCount(0); +}); + +// ─── Thread-panel gate ──────────────────────────────────────────────────────── + +test("owner can delete their owned agent's message from the thread panel", async ({ + page, +}) => { + // The bridge seeds a message from each managed agent in its channels: + // id: `mock-agents-managed-${pubkey.slice(0, 8)}` + const messageId = `mock-agents-managed-${OWNED_AGENT_PUBKEY.slice(0, 8)}`; + + await page.goto("/"); + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + + // Wait for the agent's seeded message to appear in the main timeline. + const agentRow = page.locator(`[data-message-id="${messageId}"]`).first(); + await expect(agentRow).toBeVisible({ timeout: 10_000 }); + + // Open the thread panel by hovering the message and clicking Reply. + await agentRow.hover(); + await agentRow.getByRole("button", { name: "Reply" }).click(); + + // Wait for the thread panel and confirm the thread head contains the agent message. + const threadPanel = page.getByTestId("message-thread-panel"); + await expect(threadPanel).toBeVisible({ timeout: 5_000 }); + const threadHead = threadPanel.getByTestId("message-thread-head"); + await expect( + threadHead.locator(`[data-message-id="${messageId}"]`), + ).toBeVisible({ + timeout: 5_000, + }); + + // Open the more-actions menu for the thread head from inside the thread panel. + const headRow = threadHead.locator(`[data-message-id="${messageId}"]`); + await headRow.hover(); + await threadHead.getByTestId(`more-actions-${messageId}`).click(); + // Wait for the dropdown to mount. + await expect(page.locator('[role="menuitem"]').first()).toBeVisible({ + timeout: 5_000, + }); + + // Click Delete. + // Radix dropdown portals render outside the thread-panel DOM boundary, so we + // query from the full page. Only one menu is open, making the testId unique. + await page.getByTestId(`delete-message-${messageId}`).click(); + + // The AlertDialog must appear. This proves canManageMessageForCurrentUser is + // wired into MessageThreadPanel.tsx — the delete handler is reachable from + // the thread-panel path (i.e. the thread-panel permission gate is not + // accidentally more restrictive than the main-timeline gate). The full + // delete-and-cache-invalidation path is already covered by the main-timeline + // delete test above; asserting the dialog here is sufficient as a thread-panel + // regression guard. + await expect(page.getByRole("alertdialog")).toBeVisible({ timeout: 5_000 }); + + // Dismiss without confirming — leaves the fixture clean for any subsequent steps. + await page.keyboard.press("Escape"); + await expect(page.getByRole("alertdialog")).not.toBeVisible({ + timeout: 3_000, + }); +}); + +// ─── Channel management gate ────────────────────────────────────────────────── + +test("owner can edit channel name via owned-agent-owner path", async ({ + page, +}) => { + const newChannelName = `renamed-${Date.now()}`; + + await page.goto("/"); + + // Add OwnedBot as an owner-role member of #random. + // In #random the mock identity is only a plain member — selfMember.role !== "owner". + // This isolates canManageOwnedAgentChannel as the sole reason Edit appears. + await invoke(page, "add_channel_members", { + channelId: RANDOM_CHANNEL_ID, + pubkeys: [OWNED_AGENT_PUBKEY], + role: "owner", + }); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await page.getByTestId("channel-management-trigger").click(); + await expect(page.getByTestId("channel-management-sheet")).toBeVisible(); + + // Edit quick-action must be visible: canManageOwnedAgentChannel is true. + const editButton = page.getByTestId("channel-management-edit"); + await expect(editButton).toBeVisible({ timeout: 5_000 }); + await editButton.click(); + + // Change the channel name and save. + const nameInput = page.getByTestId("channel-management-name"); + await expect(nameInput).toBeVisible({ timeout: 5_000 }); + await nameInput.clear(); + await nameInput.fill(newChannelName); + await page.getByTestId("channel-management-save-changes").click(); + + // The edit dialog must close and the updated name must appear in the header. + await expect(page.getByTestId("channel-management-name")).toBeHidden(); + await expect(page.getByTestId("chat-title")).toHaveText(newChannelName); +}); + +test("owner does NOT see channel Edit button when no owned agent is a channel owner", async ({ + page, +}) => { + await page.goto("/"); + + // #random has alice as owner; mock identity is a plain member. + // OwnedBot is NOT added as owner in this test — Edit must not appear. + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await page.getByTestId("channel-management-trigger").click(); + await expect(page.getByTestId("channel-management-sheet")).toBeVisible(); + + await expect(page.getByTestId("channel-management-edit")).toBeHidden(); +});