feat(relay): allow agent owners to edit/manage agent-owned content (#1403)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-30 18:31:47 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 06ef533ec7
commit 0042c8e106
15 changed files with 1472 additions and 56 deletions
Generated
+1
View File
@@ -1109,6 +1109,7 @@ dependencies = [
"anyhow",
"base64",
"buzz-core",
"buzz-sdk",
"buzz-ws-client",
"chrono",
"futures-util",
+43 -5
View File
@@ -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
+89 -10
View File
@@ -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<AppState>,
community_id: buzz_core::CommunityId,
members: &[buzz_db::channel::MemberRecord],
actor_bytes: &[u8],
) -> anyhow::Result<bool> {
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 => {
+1
View File
@@ -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"
+12
View File
@@ -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<OkResponse, TestClientError> {
Ok(self.inner.send_event(event).await?)
@@ -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();
}
+1
View File
@@ -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"],
@@ -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({
<Archive className="h-4 w-4" />
</Button>
)}
{isOwner ? (
{canDeleteChannel ? (
<AlertDialog
onOpenChange={handleDeleteDialogOpenChange}
open={isDeleteDialogOpen}
@@ -37,8 +37,11 @@ import {
formatTtlDuration,
parseTtlDuration,
} from "@/features/channels/lib/ephemeralChannel";
import { ownsAuthorAgent } from "@/features/profile/lib/identity";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { useTheme } from "@/shared/theme/ThemeProvider";
import { Button } from "@/shared/ui/button";
import {
@@ -133,9 +136,38 @@ export function ChannelManagementSheet({
const selfMember =
members.find((member) => 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}
/>
@@ -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,
);
}
@@ -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
}
@@ -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;
@@ -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;
+22
View File
@@ -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<typeof handleSendManagedAgentChannelMessage>[0],
activeConfig,
);
case "delete_message":
handleDeleteMessage(
payload as Parameters<typeof handleDeleteMessage>[0],
);
return null;
case "edit_message":
return handleEditMessage(
payload as Parameters<typeof handleEditMessage>[0],
@@ -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<string, unknown>,
): Promise<unknown> {
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<string, unknown>,
) => Promise<unknown>;
};
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();
});