diff --git a/desktop/src-tauri/src/commands/messages/forum.rs b/desktop/src-tauri/src/commands/messages/forum.rs index 086e8c9f793..024095d9fcc 100644 --- a/desktop/src-tauri/src/commands/messages/forum.rs +++ b/desktop/src-tauri/src/commands/messages/forum.rs @@ -154,6 +154,63 @@ pub(super) fn apply_link_preview_suppression( } } +/// Fold the latest kind:40003 edit content into an original event. +/// +/// A 40003 edit is valid when its signer is the original's author or the +/// author's verified agent owner. Among valid edits targeting the same +/// original, the one with the highest `created_at` wins. The edit's content +/// replaces the original's content; the edit's tags (excluding the routing +/// `e` tag and any `link-preview` marker, which are already handled +/// separately) are not folded — only the text changes. +/// +/// `owner_pubkeys` maps **agent pubkey → owner pubkey** (built by +/// `fetch_agent_owner_pubkeys` from NIP-OA `auth` tags on kind:0 profiles). +/// The lookup is one-directional: an owner can edit their agent's posts, but +/// an agent cannot edit their owner's posts. When the original author is a +/// human (no NIP-OA profile), the map has no entry, so only the author +/// themself is authorized. +/// +/// Returns the edited content when a valid edit exists, or `None` when the +/// original should be used as-is. +pub(super) fn fold_edit_content( + original: &nostr::Event, + edits: &[nostr::Event], + owner_pubkeys: &std::collections::HashMap, +) -> Option { + let original_author = original.pubkey.to_hex(); + let original_id = original.id.to_hex(); + + let mut best: Option<&nostr::Event> = None; + for edit in edits { + if edit.kind.as_u16() != 40003 { + continue; + } + // Find the target event id from the e tag. + let target_id = edit.tags.iter().find_map(|tag| { + let values = tag.as_slice(); + (values.first().map(String::as_str) == Some("e")) + .then(|| values.get(1).map(String::as_str)) + .flatten() + }); + if target_id != Some(original_id.as_str()) { + continue; + } + // Authorization: signer must be the original author or the + // author's verified agent owner. + let signer = edit.pubkey.to_hex(); + let authorized = signer == original_author + || owner_pubkeys.get(&original_author) == Some(&signer); + if !authorized { + continue; + } + // Latest edit wins. + if best.is_none_or(|b| edit.created_at > b.created_at) { + best = Some(edit); + } + } + best.map(|e| e.content.clone()) +} + #[tauri::command] pub async fn get_forum_posts( channel_id: String, @@ -191,6 +248,9 @@ pub async fn get_forum_posts( .iter() .map(|ev| { let mut message = forum_message_from_event(ev, &channel_id); + if let Some(edited_content) = fold_edit_content(ev, &edits, &owner_pubkeys) { + message.content = edited_content; + } apply_link_preview_suppression(&mut message.tags, &message.event_id, &suppressed); message }) @@ -248,10 +308,16 @@ pub async fn get_forum_thread( for ev in &events { if ev.id.to_hex() == event_id { let mut message = forum_message_from_event(ev, &channel_id); + if let Some(edited_content) = fold_edit_content(ev, &edits, &owner_pubkeys) { + message.content = edited_content; + } apply_link_preview_suppression(&mut message.tags, &message.event_id, &suppressed); root = Some(message); } else if ev.kind.as_u16() as u32 != 40003 { let mut reply = forum_reply_from_event(ev, &channel_id, &event_id); + if let Some(edited_content) = fold_edit_content(ev, &edits, &owner_pubkeys) { + reply.content = edited_content; + } apply_link_preview_suppression(&mut reply.tags, &reply.event_id, &suppressed); replies.push(reply); } @@ -315,4 +381,109 @@ mod tests { ) .is_empty()); } + + fn edit_event( + keys: &Keys, + content: &str, + target_id: &str, + created_at: nostr::Timestamp, + ) -> nostr::Event { + let tags = vec![nostr::Tag::parse(["e", target_id]).unwrap()]; + EventBuilder::new(Kind::Custom(40003), content) + .tags(tags) + .custom_created_at(created_at) + .sign_with_keys(keys) + .expect("edit signs") + } + + #[test] + fn fold_edit_content_applies_author_edit() { + let author = Keys::generate(); + let original = signed_event(&author, 45001, Vec::new()); + let edit = edit_event(&author, "edited body", &original.id.to_hex(), nostr::Timestamp::now()); + let owners = std::collections::HashMap::new(); + let result = fold_edit_content(&original, std::slice::from_ref(&edit), &owners); + assert_eq!(result.as_deref(), Some("edited body")); + } + + #[test] + fn fold_edit_content_applies_owner_edit() { + let author = Keys::generate(); + let owner = Keys::generate(); + let original = signed_event(&author, 45001, Vec::new()); + let edit = edit_event(&owner, "owner-edited", &original.id.to_hex(), nostr::Timestamp::now()); + let owners = std::collections::HashMap::from([( + author.public_key().to_hex(), + owner.public_key().to_hex(), + )]); + let result = fold_edit_content(&original, std::slice::from_ref(&edit), &owners); + assert_eq!(result.as_deref(), Some("owner-edited")); + } + + #[test] + fn fold_edit_content_rejects_unauthorized_signer() { + let author = Keys::generate(); + let attacker = Keys::generate(); + let original = signed_event(&author, 45001, Vec::new()); + let edit = edit_event(&attacker, "hacked", &original.id.to_hex(), nostr::Timestamp::now()); + let owners = std::collections::HashMap::new(); + let result = fold_edit_content(&original, std::slice::from_ref(&edit), &owners); + assert!(result.is_none(), "unauthorized edit must not apply"); + } + + #[test] + fn fold_edit_content_latest_edit_wins() { + let author = Keys::generate(); + let original = signed_event(&author, 45001, Vec::new()); + let older = edit_event(&author, "first", &original.id.to_hex(), nostr::Timestamp::from_secs(100)); + let newer = edit_event(&author, "second", &original.id.to_hex(), nostr::Timestamp::from_secs(200)); + let owners = std::collections::HashMap::new(); + let edits = [older, newer]; + let result = fold_edit_content(&original, &edits, &owners); + assert_eq!(result.as_deref(), Some("second")); + } + + #[test] + fn fold_edit_content_ignores_edit_targeting_different_event() { + let author = Keys::generate(); + let original = signed_event(&author, 45001, Vec::new()); + // Distinct created_at so `other` has a different event id from `original`. + // Without this, both events share the same author, kind, content, and + // timestamp, producing the same event id — the edit targeting `other` + // would then match `original`, defeating the test's purpose. + let other = EventBuilder::new(Kind::Custom(45001), "body") + .custom_created_at(nostr::Timestamp::from_secs(1)) + .sign_with_keys(&author) + .expect("event signs"); + let edit = edit_event(&author, "wrong target", &other.id.to_hex(), nostr::Timestamp::now()); + let owners = std::collections::HashMap::new(); + let result = fold_edit_content(&original, std::slice::from_ref(&edit), &owners); + assert!(result.is_none(), "edit targeting a different event must not apply"); + } + + #[test] + fn fold_edit_content_rejects_agent_editing_owner_post() { + // The owner_pubkeys map is agent → owner, not bidirectional. + // A human (owner) post must NOT be editable by their agent, + // because the map has no entry keyed by the human's pubkey. + let owner = Keys::generate(); + let agent = Keys::generate(); + let original = signed_event(&owner, 45001, Vec::new()); + let edit = edit_event(&agent, "agent-edited", &original.id.to_hex(), nostr::Timestamp::now()); + let owners = std::collections::HashMap::from([( + agent.public_key().to_hex(), + owner.public_key().to_hex(), + )]); + let result = fold_edit_content(&original, std::slice::from_ref(&edit), &owners); + assert!(result.is_none(), "agent must not edit owner's post"); + } + + #[test] + fn fold_edit_content_no_edits_returns_none() { + let author = Keys::generate(); + let original = signed_event(&author, 45001, Vec::new()); + let owners = std::collections::HashMap::new(); + let result = fold_edit_content(&original, &[], &owners); + assert!(result.is_none()); + } }