diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index f80f928d316..687eafd40c6 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -889,8 +889,13 @@ pub async fn cmd_edit_message( let channel_uuid = resolve_channel_id(client, event_id).await?; let target_eid = parse_event_id(event_id)?; - let builder = buzz_sdk::build_edit(channel_uuid, target_eid, content) - .map_err(|e| CliError::Other(format!("build_edit failed: {e}")))?; + let builder = buzz_sdk::build_edit_with_editor( + channel_uuid, + target_eid, + content, + Some(&client.keys().public_key().to_hex()), + ) + .map_err(|e| CliError::Other(format!("build_edit failed: {e}")))?; let event = client.sign_event(builder)?; diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 3f767c18741..8030e7e814e 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -578,6 +578,59 @@ async fn enqueue_event_created_audit( // DB is genuinely overloaded and the relay should slow down rather than // accumulate unbounded in-memory state. DB write failures in the worker are // logged but not retried (same as the previous per-event tokio::spawn). + let mut detail = serde_json::json!({ + "event_kind": kind_u32, + "channel_id": stored_event.channel_id, + }); + if kind_u32 == buzz_core::kind::KIND_STREAM_MESSAGE_EDIT { + let target_id = stored_event.event.tags.iter().find_map(|tag| { + if tag.kind().to_string() == "e" { + tag.content().and_then(|value| { + let bytes = hex::decode(value).ok()?; + (bytes.len() == 32).then_some(hex::encode(bytes)) + }) + } else { + None + } + }); + if let Some(target_id) = target_id { + detail["target_event_id"] = serde_json::Value::String(target_id.clone()); + if let Some(edited_by) = stored_event.event.tags.iter().find_map(|tag| { + (tag.kind().to_string() == "edited_by") + .then(|| tag.content().map(str::to_string)) + .flatten() + }) { + detail["edited_by"] = serde_json::Value::String(edited_by); + } + if let (Some(channel_id), Ok(Some(target_event))) = ( + stored_event.channel_id, + state + .db + .get_event_by_id_for_event_write( + tenant.community(), + &hex::decode(&target_id).unwrap_or_default(), + ) + .await, + ) { + let author = super::ingest::effective_message_author( + &target_event.event, + &state.relay_keypair.public_key(), + ); + let members = state.db.get_members(tenant.community(), channel_id).await; + let actor = stored_event.event.pubkey.to_bytes().to_vec(); + let is_channel_admin = members.is_ok_and(|members| { + members.iter().any(|member| { + member.pubkey == actor && (member.role == "owner" || member.role == "admin") + }) + }); + if author != actor && is_channel_admin { + detail["type"] = + serde_json::Value::String("message_edited_by_admin".to_string()); + detail["actor"] = serde_json::Value::String(actor_pubkey_hex.to_string()); + } + } + } + } let audit_entry = buzz_audit::NewAuditEntry { community_id: tenant.community(), action: buzz_audit::AuditAction::EventCreated, @@ -589,10 +642,7 @@ async fn enqueue_event_created_audit( // the pre-rewrite semantics, ported to the raw-bytes column. actor_pubkey: hex::decode(actor_pubkey_hex).ok(), object_id: Some(event_id_hex.to_owned()), - detail: serde_json::json!({ - "event_kind": kind_u32, - "channel_id": stored_event.channel_id, - }), + detail, }; if let Err(e) = audit_tx.send(audit_entry).await { error!(event_id = %event_id_hex, "Audit channel closed — entry lost: {e}"); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 7f343c2c10c..a7e37c06956 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -1144,8 +1144,42 @@ pub(crate) fn effective_message_author(event: &Event, relay_pubkey: &nostr::Publ event.pubkey.to_bytes().to_vec() } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EditAuthorization { + Author, + ChannelOwnerOrAdmin, + AgentOwner, +} + +fn authorize_edit_actor( + actor: &[u8], + author: &[u8], + same_channel: bool, + channel_owner_or_admin: bool, + agent_owner: bool, + provenance_matches: bool, +) -> Result { + if !same_channel { + return Err("target event belongs to a different channel".to_string()); + } + if actor == author { + return Ok(EditAuthorization::Author); + } + if !provenance_matches { + return Err("non-author edits must carry an edited_by tag matching the editor".to_string()); + } + if channel_owner_or_admin { + return Ok(EditAuthorization::ChannelOwnerOrAdmin); + } + if agent_owner { + return Ok(EditAuthorization::AgentOwner); + } + Err("must be event author or channel owner/admin".to_string()) +} + /// 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. +/// or the actor must be a channel owner/admin or the owning human of the agent +/// that authored the target message. async fn validate_edit_ownership( community_id: CommunityId, event: &Event, @@ -1180,48 +1214,86 @@ async fn validate_edit_ownership( // Verify target belongs to the same channel as the edit event. let edit_channel_id = extract_channel_id(event); - match (edit_channel_id, target_event.channel_id) { + let same_channel = match (edit_channel_id, target_event.channel_id) { (Some(edit_ch), Some(target_ch)) if edit_ch != target_ch => { return Err("target event belongs to a different channel".to_string()); } (Some(_), None) => { return Err("target event has no channel".to_string()); } - _ => {} // Same channel or no channel context — OK - } + _ => true, // Same channel or no channel context — OK + }; let author = effective_message_author(&target_event.event, &state.relay_keypair.public_key()); let actor = event.pubkey.to_bytes().to_vec(); - 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_for_event_write(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()); - } - } + let edited_by = event.tags.iter().find_map(|tag| { + if tag.kind().to_string() == "edited_by" { + tag.content().map(str::to_string) + } else { + None } + }); + let actor_hex = hex::encode(&actor); + let provenance_matches = edited_by + .as_deref() + .map(|value| value.eq_ignore_ascii_case(&actor_hex)) + .unwrap_or(false); + + let channel_owner_or_admin = if author == actor { + false + } else if let Some(ch_id) = target_event.channel_id { + let members = state + .db + .get_members(community_id, ch_id) + .await + .map_err(|e| format!("db error checking channel role: {e}"))?; + members.iter().any(|member| { + member.pubkey == actor && (member.role == "owner" || member.role == "admin") + }) } else { - // Allow the owning human to edit messages authored by their agent. - let is_owner = state + false + }; + + let agent_owner = if author == actor || channel_owner_or_admin { + false + } else { + 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()); + .map_err(|e| format!("db error checking agent ownership: {e}"))? + }; + + match authorize_edit_actor( + &actor, + &author, + same_channel, + channel_owner_or_admin, + agent_owner, + provenance_matches, + )? { + EditAuthorization::Author => { + // 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_for_event_write(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()); + } + } + } } + EditAuthorization::ChannelOwnerOrAdmin | EditAuthorization::AgentOwner => {} } Ok(()) } @@ -3309,6 +3381,56 @@ mod postgres_tests { }; use nostr::{EventBuilder, Kind}; + #[test] + fn non_author_non_admin_edit_is_rejected() { + let actor = [1_u8; 32]; + let author = [2_u8; 32]; + let error = authorize_edit_actor(&actor, &author, true, false, false, true) + .expect_err("ordinary members must not edit another author's message"); + assert_eq!(error, "must be event author or channel owner/admin"); + } + + #[test] + fn same_channel_admin_edit_is_authorized_with_provenance() { + let actor = [1_u8; 32]; + let author = [2_u8; 32]; + assert_eq!( + authorize_edit_actor(&actor, &author, true, true, false, true), + Ok(EditAuthorization::ChannelOwnerOrAdmin) + ); + } + + #[test] + fn admin_of_another_channel_is_rejected_before_role_authorization() { + let actor = [1_u8; 32]; + let author = [2_u8; 32]; + let error = authorize_edit_actor(&actor, &author, false, true, false, true) + .expect_err("channel roles must not cross channel boundaries"); + assert_eq!(error, "target event belongs to a different channel"); + } + + #[test] + fn channel_owner_edit_is_authorized_with_provenance() { + let actor = [1_u8; 32]; + let author = [2_u8; 32]; + assert_eq!( + authorize_edit_actor(&actor, &author, true, true, false, true), + Ok(EditAuthorization::ChannelOwnerOrAdmin) + ); + } + + #[test] + fn non_author_edit_requires_matching_provenance() { + let actor = [1_u8; 32]; + let author = [2_u8; 32]; + let error = authorize_edit_actor(&actor, &author, true, true, false, false) + .expect_err("admin edits must identify their signed editor"); + assert_eq!( + error, + "non-author edits must carry an edited_by tag matching the editor" + ); + } + #[test] fn missing_huddle_backing_channel_is_a_client_rejection() { let channel_id = Uuid::new_v4(); diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index f63c174d961..1aa16c366e0 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -11,7 +11,7 @@ use buzz_core::kind::{ KIND_GIT_REPO_ANNOUNCEMENT, KIND_IA_ARCHIVED, KIND_IA_ARCHIVED_LIST, KIND_IA_UNARCHIVED, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, - KIND_THREAD_SUMMARY, + KIND_STREAM_MESSAGE_EDIT, KIND_THREAD_SUMMARY, }; use buzz_core::StoredEvent; use buzz_db::channel::{MemberRecord, MemberRole}; @@ -34,7 +34,7 @@ pub fn is_admin_kind(kind: u32) -> bool { /// handled in `ingest_event()` before storage so we can short-circuit on /// duplicates without storing the event at all. pub fn is_side_effect_kind(kind: u32) -> bool { - matches!(kind, 0 | 5 | 9000..=9022 | KIND_GIT_REPO_ANNOUNCEMENT | KIND_AGENT_PROFILE | 41001..=41003 | 40099) + matches!(kind, 0 | 5 | 9000..=9022 | KIND_GIT_REPO_ANNOUNCEMENT | KIND_AGENT_PROFILE | KIND_STREAM_MESSAGE_EDIT | 41001..=41003 | 40099) } /// Apply the three live side effects that must follow a successful admin kick: @@ -338,6 +338,7 @@ pub async fn handle_side_effects( 9001 => handle_remove_user(tenant, event, state).await, 9002 => handle_edit_metadata(tenant, event, state).await, 9005 => handle_delete_event_side_effect(tenant, event, state).await, + KIND_STREAM_MESSAGE_EDIT => handle_message_edit_side_effect(tenant, event, state).await, 9007 => handle_create_group(tenant, event, state).await, 9008 => handle_delete_group(tenant, event, state).await, 9009 => { @@ -357,6 +358,78 @@ pub async fn handle_side_effects( } } +fn admin_edit_system_message_content( + actor_hex: String, + target_event_id: String, + channel_id: Uuid, +) -> serde_json::Value { + serde_json::json!({ + "type": "message_edited_by_admin", + "actor": actor_hex, + "target_event_id": target_event_id, + "channel": channel_id.to_string(), + }) +} + +async fn handle_message_edit_side_effect( + tenant: &TenantContext, + event: &Event, + state: &Arc, +) -> anyhow::Result<()> { + let channel_id = + extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!("missing h tag"))?; + let target_id = event + .tags + .iter() + .find_map(|tag| { + if tag.kind().to_string() == "e" { + tag.content().and_then(|value| { + let bytes = hex::decode(value).ok()?; + (bytes.len() == 32).then_some(bytes) + }) + } else { + None + } + }) + .ok_or_else(|| anyhow::anyhow!("missing e tag for edit target"))?; + let target_event = state + .db + .get_event_by_id_for_event_write(tenant.community(), &target_id) + .await + .map_err(|e| anyhow::anyhow!("get edit target failed: {e}"))? + .ok_or_else(|| anyhow::anyhow!("edit target event not found"))?; + + if target_event.channel_id != Some(channel_id) { + return Err(anyhow::anyhow!( + "target event belongs to a different channel" + )); + } + + let actor = event.pubkey.to_bytes().to_vec(); + let author = effective_message_author(&target_event.event, &state.relay_keypair.public_key()); + if author == actor { + return Ok(()); + } + + let members = state + .db + .get_members(tenant.community(), channel_id) + .await + .map_err(|e| anyhow::anyhow!("get channel members failed: {e}"))?; + if !actor_is_channel_owner_or_admin(&members, &actor) { + // Agent-owner edits retain their existing authorization but are not + // channel-admin moderation actions, so they have no room-facing audit row. + return Ok(()); + } + + let content = + admin_edit_system_message_content(hex::encode(actor), hex::encode(&target_id), channel_id); + let timestamp = + chrono::DateTime::::from_timestamp(event.created_at.as_secs() as i64, 0) + .unwrap_or_else(chrono::Utc::now); + emit_system_message(tenant, state, channel_id, content, timestamp).await +} + /// Validate a standard NIP-09 deletion event before it is stored. /// /// Buzz accepts standard deletions for self-authored events, plus the owning @@ -3949,4 +4022,32 @@ mod tests { assert!(actor_is_channel_owner_or_admin(&members, &actor)); } + + #[test] + fn owner_role_is_owner_or_admin_for_message_edit_audit() { + let channel_id = Uuid::new_v4(); + let actor = vec![7_u8; 32]; + let members = vec![MemberRecord { + channel_id, + pubkey: actor.clone(), + role: "owner".to_string(), + joined_at: chrono::Utc::now(), + invited_by: None, + removed_at: None, + }]; + + assert!(actor_is_channel_owner_or_admin(&members, &actor)); + } + + #[test] + fn admin_edit_system_message_contains_audit_provenance() { + let channel_id = Uuid::new_v4(); + let content = + admin_edit_system_message_content("aabb".to_string(), "ccdd".to_string(), channel_id); + + assert_eq!(content["type"], "message_edited_by_admin"); + assert_eq!(content["actor"], "aabb"); + assert_eq!(content["target_event_id"], "ccdd"); + assert_eq!(content["channel"], channel_id.to_string()); + } } diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index f43887b65b1..bdb1afdb0fe 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -408,12 +408,30 @@ pub fn build_edit( channel_id: Uuid, target_event_id: nostr::EventId, new_content: &str, +) -> Result { + build_edit_with_editor(channel_id, target_event_id, new_content, None) +} + +/// Build an edit event with the signing editor's provenance. +/// +/// The `edited_by` tag is part of the signed event, so relay-authorized +/// channel-admin edits can be attributed without changing the original +/// message author. Pass the public key that will sign the returned builder. +pub fn build_edit_with_editor( + channel_id: Uuid, + target_event_id: nostr::EventId, + new_content: &str, + editor_pubkey: Option<&str>, ) -> Result { check_content(new_content, 64 * 1024)?; - let tags = vec![ + let mut tags = vec![ tag(&["h", &channel_id.to_string()])?, tag(&["e", &target_event_id.to_hex()])?, ]; + if let Some(editor_pubkey) = editor_pubkey { + let editor_pubkey = check_pubkey_hex(editor_pubkey, "edited_by")?; + tags.push(tag(&["edited_by", &editor_pubkey])?); + } Ok(EventBuilder::new(Kind::Custom(40003), new_content).tags(tags)) } @@ -2830,6 +2848,21 @@ mod tests { assert!(has_tag(&ev, "e", &eid.to_hex())); } + #[test] + fn edit_with_editor_records_signed_provenance() { + let cid = uuid(); + let eid = event_id(); + let keys = nostr::Keys::generate(); + let ev = keys + .sign_event( + build_edit_with_editor(cid, eid, "new content", Some(&keys.public_key().to_hex())) + .unwrap(), + ) + .unwrap(); + + assert!(has_tag(&ev, "edited_by", &keys.public_key().to_hex())); + } + #[test] fn edit_content_too_large() { let cid = uuid(); diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 1e221b6bd18..5d1646d946c 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -19,6 +19,7 @@ use crate::{ nostr_convert, relay::{ assert_expected_relay_scope, assert_expected_signer, query_relay, submit_event, + submit_event_with_keys, submit_event_at_created_at, submit_event_with_keys_created_at, }, }; @@ -903,8 +904,10 @@ pub async fn edit_message( if trimmed.is_empty() && input.media_tags.is_empty() { return Err("edit must have content or attachments".into()); } + let keys = state.signing_keys()?; + let editor_pubkey = keys.public_key().to_hex(); let mention_refs: Vec<&str> = input.mention_pubkeys.iter().map(|s| s.as_str()).collect(); - let builder = events::build_message_edit( + let builder = events::build_message_edit_with_editor( channel_uuid, target_eid, trimmed, @@ -915,8 +918,9 @@ pub async fn edit_message( mention_refs: input.mention_tags.as_deref(), }, input.suppress_link_previews, + Some(&editor_pubkey), )?; - submit_event(builder, &state).await?; + submit_event_with_keys(builder, &state, &keys, None).await?; Ok(()) } diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 1828b3f5605..34838cc367c 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -362,12 +362,35 @@ pub fn build_message_edit( content: &str, edit_tags: MessageEditTags<'_>, suppress_link_previews: bool, +) -> Result { + build_message_edit_with_editor( + channel_id, + target_event_id, + content, + edit_tags, + suppress_link_previews, + None, + ) +} + +/// Kind 40003 — build an edit carrying the signed editor's provenance. +pub fn build_message_edit_with_editor( + channel_id: Uuid, + target_event_id: EventId, + content: &str, + edit_tags: MessageEditTags<'_>, + suppress_link_previews: bool, + editor_pubkey: Option<&str>, ) -> Result { check_content(content)?; let mut tags = vec![ tag(vec!["h", &channel_id.to_string()])?, tag(vec!["e", &target_event_id.to_hex()])?, ]; + if let Some(editor_pubkey) = editor_pubkey { + check_pubkey(editor_pubkey)?; + tags.push(tag(vec!["edited_by", editor_pubkey])?); + } tags.extend(mention_tags(edit_tags.mentions)?); imeta_tags(edit_tags.media, &mut tags)?; emoji_tags(edit_tags.custom_emoji, &mut tags)?; diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index 4b74930da8f..466deb9f440 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -641,6 +641,11 @@ function describeSystemEvent( action: "removed a message", }; } + case "message_edited_by_admin": + return { + title: actorName, + action: "edited a message", + }; default: return null; }