From 970f10d99d25ae65394ff24458f38a882c9da0fb Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:47:06 -0500 Subject: [PATCH 1/3] fix(desktop): fold kind 40003 edit content into forum posts and comments Desktop already fetches kind:40003 edit events alongside forum posts and thread replies, but only uses them for link-preview suppression. The edit content is never applied to the original message, so users always see the original text even after a successful edit. Added fold_edit_content, which finds the latest valid 40003 edit for a given original event and returns its content. A valid edit must be signed by the original author or the author's verified agent owner (same authorization as the existing link-preview suppression path). Applied in both get_forum_posts and get_forum_thread so edited content shows in both the channel list and the thread view. Six new unit tests cover: author edit, owner edit, unauthorized signer rejection, latest-edit-wins ordering, wrong-target rejection, and the no-edits case. Closes #6433 Co-authored-by: Brad Groux Signed-off-by: Brad Groux Signed-off-by: Brad Groux Signed-off-by: Brad Groux <3053586+BradGroux@users.noreply.github.com> --- .../src-tauri/src/commands/messages/forum.rs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/desktop/src-tauri/src/commands/messages/forum.rs b/desktop/src-tauri/src/commands/messages/forum.rs index 086e8c9f793..b539adce0e2 100644 --- a/desktop/src-tauri/src/commands/messages/forum.rs +++ b/desktop/src-tauri/src/commands/messages/forum.rs @@ -154,6 +154,56 @@ 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. +/// +/// 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 +241,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 +301,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 +374,85 @@ 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()); + let other = signed_event(&author, 45001, Vec::new()); + 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_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()); + } } From 0a319ef976dafbe44583555e88a75a6ae423cc16 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:27:13 -0500 Subject: [PATCH 2/3] fix: make fold_edit_content test use distinct event ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fold_edit_content_ignores_edit_targeting_different_event test created two events with the same author, kind, content, and timestamp. When Timestamp::now() returns the same second for both, they produce identical event ids, so the edit targeting 'other' actually matches 'original' — the test passes for the wrong reason or fails depending on timing. Give the second event a distinct created_at so its event id is guaranteed different. Co-authored-by: Brad Groux Signed-off-by: Brad Groux Signed-off-by: Brad Groux Signed-off-by: Brad Groux <3053586+BradGroux@users.noreply.github.com> --- desktop/src-tauri/src/commands/messages/forum.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/commands/messages/forum.rs b/desktop/src-tauri/src/commands/messages/forum.rs index b539adce0e2..70bf54efe3a 100644 --- a/desktop/src-tauri/src/commands/messages/forum.rs +++ b/desktop/src-tauri/src/commands/messages/forum.rs @@ -440,7 +440,14 @@ mod tests { fn fold_edit_content_ignores_edit_targeting_different_event() { let author = Keys::generate(); let original = signed_event(&author, 45001, Vec::new()); - let other = 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); From 23e61a986d4ee7237795379869aeb250d1d4c7b0 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:11:35 -0500 Subject: [PATCH 3/3] fix: pin owner_pubkeys map direction with bidirectional test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chessing234 pointed out that owner_pubkeys.get(&original_author) only works when the map is agent→owner and the original author is an agent. Add a test that proves the reverse direction is rejected: when a human (owner) posts and their agent signs an edit, the map has no entry keyed by the human pubkey, so the edit is correctly ignored. Also clarify the doc comment to state the map direction explicitly. Co-authored-by: Brad Groux Signed-off-by: Brad Groux Signed-off-by: Brad Groux <3053586+BradGroux@users.noreply.github.com> --- .../src-tauri/src/commands/messages/forum.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/desktop/src-tauri/src/commands/messages/forum.rs b/desktop/src-tauri/src/commands/messages/forum.rs index 70bf54efe3a..024095d9fcc 100644 --- a/desktop/src-tauri/src/commands/messages/forum.rs +++ b/desktop/src-tauri/src/commands/messages/forum.rs @@ -163,6 +163,13 @@ pub(super) fn apply_link_preview_suppression( /// `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( @@ -454,6 +461,23 @@ mod tests { 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();