From 31311a3b7302d83155a70536dfb5e6a62782dd54 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Wed, 1 Jul 2026 08:54:19 +0900 Subject: [PATCH 1/3] feat(messages): carry app-defined extra tags on kind-9 chat/reply Add extra_tags to the Chat/Reply intents and expose send_message_with_tags / reply_to_message_with_tags so host apps can attach out-of-band metadata (e.g. a message-effect key) as a real nostr kind-9 tag instead of smuggling it into the visible message body. Marmot stays agnostic about tag meaning; only malformed (empty) tags are dropped. Routes through the existing SendAppEvent worker path, so no worker/command-layer changes are needed. --- crates/marmot-app/src/client/mod.rs | 6 +- crates/marmot-app/src/messages/intents.rs | 74 +++++++++++++++++++++-- crates/marmot-app/src/runtime/mod.rs | 50 +++++++++++++++ crates/marmot-app/src/tests.rs | 5 ++ 4 files changed, 129 insertions(+), 6 deletions(-) diff --git a/crates/marmot-app/src/client/mod.rs b/crates/marmot-app/src/client/mod.rs index 82136eae..3f9f907f 100644 --- a/crates/marmot-app/src/client/mod.rs +++ b/crates/marmot-app/src/client/mod.rs @@ -1028,7 +1028,10 @@ impl AppClient { let (_event, summary) = self .send_app_event_with_local_projection( group_id, - AppMessageIntent::Chat { content }, + AppMessageIntent::Chat { + content, + extra_tags: Vec::new(), + }, on_local_projection, ) .await?; @@ -1276,6 +1279,7 @@ impl AppClient { AppMessageIntent::Reply { target_message_id: target_message_id.to_owned(), text: text.to_owned(), + extra_tags: Vec::new(), }, ) .await?; diff --git a/crates/marmot-app/src/messages/intents.rs b/crates/marmot-app/src/messages/intents.rs index 7eee63ee..09c0e61d 100644 --- a/crates/marmot-app/src/messages/intents.rs +++ b/crates/marmot-app/src/messages/intents.rs @@ -146,6 +146,18 @@ fn mention_p_tags(content: &str) -> Vec> { tags } +/// Filter host-supplied `extra_tags` down to well-formed nostr tags before they +/// ride on the outgoing event. A nostr tag must have at least a name element, so +/// empty tags are dropped; everything else is carried verbatim. Marmot stays +/// agnostic about what the tags mean — the host app owns that convention. +fn sanitized_extra_tags(extra_tags: &[Vec]) -> Vec> { + extra_tags + .iter() + .filter(|tag| !tag.is_empty()) + .cloned() + .collect() +} + /// Value of the `stream-type` tag on an agent text stream start event. const STREAM_TYPE_TEXT: &str = "text"; /// Value of the `route` tag on a brokered QUIC agent text stream start event. @@ -159,6 +171,11 @@ const STREAM_FINAL_KIND_CHAT: &str = "9"; pub(crate) enum AppMessageIntent { Chat { content: String, + /// Extra application-defined tags appended verbatim to the kind-9 event + /// (after the protocol-derived ones). Lets host apps carry out-of-band + /// metadata — e.g. a message-effect key — as a real nostr tag instead of + /// smuggling it into the visible body. Empty for a plain chat. + extra_tags: Vec>, }, Reaction { target_message_id: String, @@ -170,6 +187,8 @@ pub(crate) enum AppMessageIntent { Reply { target_message_id: String, text: String, + /// See [`AppMessageIntent::Chat::extra_tags`]. + extra_tags: Vec>, }, Edit { target_message_id: String, @@ -241,11 +260,14 @@ pub(crate) fn build_inner_event( ) }; match intent { - AppMessageIntent::Chat { content } => Ok(event( - MARMOT_APP_EVENT_KIND_CHAT, - mention_p_tags(content), - content.clone(), - )), + AppMessageIntent::Chat { + content, + extra_tags, + } => { + let mut tags = mention_p_tags(content); + tags.extend(sanitized_extra_tags(extra_tags)); + Ok(event(MARMOT_APP_EVENT_KIND_CHAT, tags, content.clone())) + } AppMessageIntent::Reaction { target_message_id, emoji, @@ -265,6 +287,7 @@ pub(crate) fn build_inner_event( AppMessageIntent::Reply { target_message_id, text, + extra_tags, } => { validate_message_ref(target_message_id)?; if text.trim().is_empty() { @@ -277,6 +300,7 @@ pub(crate) fn build_inner_event( vec![QUOTE_REF_TAG.to_owned(), target_message_id.clone()], ]; tags.extend(mention_p_tags(text)); + tags.extend(sanitized_extra_tags(extra_tags)); Ok(event(MARMOT_APP_EVENT_KIND_CHAT, tags, text.clone())) } AppMessageIntent::Edit { @@ -675,11 +699,50 @@ mod mention_tests { let npub = npub_for_account_id(&hex).unwrap(); let intent = AppMessageIntent::Chat { content: format!("yo nostr:{npub}"), + extra_tags: Vec::new(), }; let event = build_inner_event(&intent, &valid_pubkey_hex(), 0).unwrap(); assert!(event.tags.contains(&vec!["p".to_owned(), hex])); } + #[test] + fn chat_intent_appends_extra_tags_verbatim_and_drops_empty() { + let intent = AppMessageIntent::Chat { + content: "hello".to_owned(), + extra_tags: vec![ + vec!["effect".to_owned(), "fire".to_owned()], + Vec::new(), // malformed — must be dropped + ], + }; + let event = build_inner_event(&intent, &valid_pubkey_hex(), 0).unwrap(); + assert_eq!(event.content, "hello", "body must stay clean"); + assert!( + event + .tags + .contains(&vec!["effect".to_owned(), "fire".to_owned()]) + ); + assert!( + event.tags.iter().all(|t| !t.is_empty()), + "empty tags must be filtered out" + ); + } + + #[test] + fn reply_intent_appends_extra_tags() { + let target = "ff".repeat(32); + let intent = AppMessageIntent::Reply { + target_message_id: target.clone(), + text: "re".to_owned(), + extra_tags: vec![vec!["effect".to_owned(), "love".to_owned()]], + }; + let event = build_inner_event(&intent, &valid_pubkey_hex(), 0).unwrap(); + assert!( + event + .tags + .contains(&vec!["effect".to_owned(), "love".to_owned()]) + ); + } + #[test] fn reply_intent_keeps_e_q_and_adds_mention_p_tag() { let hex = valid_pubkey_hex(); @@ -688,6 +751,7 @@ mod mention_tests { let intent = AppMessageIntent::Reply { target_message_id: target.clone(), text: format!("re nostr:{npub}"), + extra_tags: Vec::new(), }; let event = build_inner_event(&intent, &valid_pubkey_hex(), 0).unwrap(); assert!( diff --git a/crates/marmot-app/src/runtime/mod.rs b/crates/marmot-app/src/runtime/mod.rs index 18d88b96..0699239d 100644 --- a/crates/marmot-app/src/runtime/mod.rs +++ b/crates/marmot-app/src/runtime/mod.rs @@ -1293,6 +1293,40 @@ impl MarmotAppRuntime { Ok(summary) } + /// Like [`send_message`](Self::send_message), but appends host-supplied + /// `extra_tags` to the kind-9 event. The body stays exactly `payload`; the + /// tags travel out of band so clients that don't understand them simply + /// ignore them and render clean text. Used to carry metadata (e.g. a + /// message-effect key) without polluting the visible message content. + pub async fn send_message_with_tags( + &self, + account_ref: &str, + group_id: &GroupId, + payload: Vec, + extra_tags: Vec>, + ) -> Result { + let content = String::from_utf8(payload).map_err(|_| { + AppError::InvalidAppMessagePayload("chat message must be valid UTF-8".into()) + })?; + let summary = self + .accounts + .send_app_event( + account_ref, + group_id, + AppMessageIntent::Chat { + content, + extra_tags, + }, + ) + .await?; + let _ = self.publish_chat_list_projection_refresh( + account_ref, + &hex::encode(group_id.as_slice()), + ChatListUpdateTrigger::NewLastMessage, + ); + Ok(summary) + } + pub async fn send_agent_activity( &self, account_ref: &str, @@ -1584,6 +1618,21 @@ impl MarmotAppRuntime { group_id: &GroupId, target_message_id: &str, text: &str, + ) -> Result { + self.reply_to_message_with_tags(account_ref, group_id, target_message_id, text, Vec::new()) + .await + } + + /// Like [`reply_to_message`](Self::reply_to_message), but appends + /// host-supplied `extra_tags` to the kind-9 reply event (alongside the + /// protocol `e`/`q` tags). See [`send_message_with_tags`](Self::send_message_with_tags). + pub async fn reply_to_message_with_tags( + &self, + account_ref: &str, + group_id: &GroupId, + target_message_id: &str, + text: &str, + extra_tags: Vec>, ) -> Result { let summary = self .accounts @@ -1593,6 +1642,7 @@ impl MarmotAppRuntime { AppMessageIntent::Reply { target_message_id: target_message_id.to_owned(), text: text.to_owned(), + extra_tags, }, ) .await?; diff --git a/crates/marmot-app/src/tests.rs b/crates/marmot-app/src/tests.rs index 95fdff5b..0e99c986 100644 --- a/crates/marmot-app/src/tests.rs +++ b/crates/marmot-app/src/tests.rs @@ -1124,6 +1124,7 @@ fn build(intent: AppMessageIntent) -> MarmotInnerEvent { fn chat_intent_builds_kind_nine_with_no_tags() { let event = build(AppMessageIntent::Chat { content: "hello".to_owned(), + extra_tags: Vec::new(), }); assert_eq!(event.kind, MARMOT_APP_EVENT_KIND_CHAT); assert_eq!(event.content, "hello"); @@ -1170,6 +1171,7 @@ fn reply_intent_builds_kind_nine_with_e_and_q_tags() { let event = build(AppMessageIntent::Reply { target_message_id: "parent".to_owned(), text: "sure".to_owned(), + extra_tags: Vec::new(), }); assert_eq!(event.kind, MARMOT_APP_EVENT_KIND_CHAT); assert_eq!(event.content, "sure"); @@ -1398,6 +1400,7 @@ fn group_system_intent_builds_kind_1210_json_payload() { fn received_event_decodes_when_id_and_sender_match() { let event = build(AppMessageIntent::Chat { content: "hi".to_owned(), + extra_tags: Vec::new(), }); let bytes = event.encode().unwrap(); let group_id = GroupId::new(vec![0x01]); @@ -1513,6 +1516,7 @@ fn received_media_message_with_malformed_reference_is_rejected() { fn received_event_with_tampered_id_is_rejected() { let mut event = build(AppMessageIntent::Chat { content: "hi".to_owned(), + extra_tags: Vec::new(), }); // Mutate the content without recomputing the id: the canonical id no // longer matches, so the strict decoder must reject it. @@ -1529,6 +1533,7 @@ fn received_event_with_tampered_id_is_rejected() { fn received_event_with_wrong_sender_is_rejected() { let event = build(AppMessageIntent::Chat { content: "hi".to_owned(), + extra_tags: Vec::new(), }); let bytes = event.encode().unwrap(); let group_id = GroupId::new(vec![0x01]); From ac44e3c34b6a2348f31a7a40f472893a42d69e0f Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Thu, 2 Jul 2026 08:29:58 +0900 Subject: [PATCH 2/3] feat(messages): whitelist extra_tags to effect only Address Jeff's P1: extra_tags let callers inject reserved kind-9 semantics (e/q/imeta/stream*), so a plain text send could be projected as a reply/media/stream message. sanitized_extra_tags now whitelists a well-formed ["effect", ] tag and drops everything else. --- crates/marmot-app/src/messages/intents.rs | 73 +++++++++++++++++++---- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/crates/marmot-app/src/messages/intents.rs b/crates/marmot-app/src/messages/intents.rs index 09c0e61d..b93442b2 100644 --- a/crates/marmot-app/src/messages/intents.rs +++ b/crates/marmot-app/src/messages/intents.rs @@ -146,14 +146,27 @@ fn mention_p_tags(content: &str) -> Vec> { tags } -/// Filter host-supplied `extra_tags` down to well-formed nostr tags before they -/// ride on the outgoing event. A nostr tag must have at least a name element, so -/// empty tags are dropped; everything else is carried verbatim. Marmot stays -/// agnostic about what the tags mean — the host app owns that convention. +/// The one host-defined `extra_tags` tag name Marmot will carry today: a +/// message effect, e.g. `["effect", "fire"]`. +const EFFECT_TAG: &str = "effect"; + +/// Filter host-supplied `extra_tags` down to the tags Marmot is willing to +/// carry on an outgoing chat/reply event. +/// +/// `extra_tags` is a public authoring escape hatch, but a kind-9 event's tags +/// are load-bearing on the receive side: `e`/`q` mark a row as a reply, `imeta` +/// marks it as media, `stream*` marks it as an agent-stream final. Carrying +/// arbitrary tags verbatim would let a plain text send be projected as a +/// reply/media/stream message, so we whitelist rather than blacklist: only a +/// well-formed `["effect", ]` tag survives. Everything else — reserved +/// protocol tags, unknown names, and malformed effect tags — is dropped. When +/// the set of app-defined tags grows, widen this whitelist deliberately. fn sanitized_extra_tags(extra_tags: &[Vec]) -> Vec> { extra_tags .iter() - .filter(|tag| !tag.is_empty()) + .filter(|tag| { + matches!(tag.as_slice(), [name, value, ..] if name == EFFECT_TAG && !value.is_empty()) + }) .cloned() .collect() } @@ -171,10 +184,12 @@ const STREAM_FINAL_KIND_CHAT: &str = "9"; pub(crate) enum AppMessageIntent { Chat { content: String, - /// Extra application-defined tags appended verbatim to the kind-9 event - /// (after the protocol-derived ones). Lets host apps carry out-of-band - /// metadata — e.g. a message-effect key — as a real nostr tag instead of - /// smuggling it into the visible body. Empty for a plain chat. + /// Extra application-defined tags appended to the kind-9 event (after the + /// protocol-derived ones). Lets host apps carry out-of-band metadata as a + /// real nostr tag instead of smuggling it into the visible body. Only a + /// well-formed `["effect", ]` tag is carried today; every other + /// tag (reserved protocol tags included) is dropped by + /// [`sanitized_extra_tags`]. Empty for a plain chat. extra_tags: Vec>, }, Reaction { @@ -706,12 +721,17 @@ mod mention_tests { } #[test] - fn chat_intent_appends_extra_tags_verbatim_and_drops_empty() { + fn chat_intent_carries_effect_tag_and_drops_everything_else() { let intent = AppMessageIntent::Chat { content: "hello".to_owned(), extra_tags: vec![ vec!["effect".to_owned(), "fire".to_owned()], - Vec::new(), // malformed — must be dropped + Vec::new(), // malformed — dropped + vec!["effect".to_owned()], // no value — dropped + vec!["effect".to_owned(), String::new()], // empty value — dropped + vec!["e".to_owned(), "ff".repeat(32)], // reserved reply ref — dropped + vec!["imeta".to_owned(), "url ...".to_owned()], // reserved media — dropped + vec!["stream".to_owned(), "x".to_owned()], // reserved stream — dropped ], }; let event = build_inner_event(&intent, &valid_pubkey_hex(), 0).unwrap(); @@ -722,8 +742,35 @@ mod mention_tests { .contains(&vec!["effect".to_owned(), "fire".to_owned()]) ); assert!( - event.tags.iter().all(|t| !t.is_empty()), - "empty tags must be filtered out" + event + .tags + .iter() + .all(|t| t.first().map(String::as_str) != Some("e")), + "reserved 'e' tag must not be injectable via extra_tags" + ); + assert!( + event + .tags + .iter() + .all(|t| t.first().map(String::as_str) != Some("imeta")), + "reserved 'imeta' tag must not be injectable via extra_tags" + ); + assert!( + event + .tags + .iter() + .all(|t| t.first().map(String::as_str) != Some("stream")), + "reserved 'stream' tag must not be injectable via extra_tags" + ); + // Only the one well-formed effect tag rode along beside the derived p-tags. + assert_eq!( + event + .tags + .iter() + .filter(|t| t.first().map(String::as_str) == Some("effect")) + .count(), + 1, + "only the well-formed effect tag survives" ); } From faddb20aa9e4427553339416663d16266c4685c0 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Thu, 2 Jul 2026 08:38:17 +0900 Subject: [PATCH 3/3] fix(messages): require exact ["effect", value] shape in extra_tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten sanitized_extra_tags so an effect tag with trailing fields is dropped too — enforce exactly two elements, not a two-or-more prefix. --- crates/marmot-app/src/messages/intents.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/marmot-app/src/messages/intents.rs b/crates/marmot-app/src/messages/intents.rs index b93442b2..9e10a00b 100644 --- a/crates/marmot-app/src/messages/intents.rs +++ b/crates/marmot-app/src/messages/intents.rs @@ -165,7 +165,7 @@ fn sanitized_extra_tags(extra_tags: &[Vec]) -> Vec> { extra_tags .iter() .filter(|tag| { - matches!(tag.as_slice(), [name, value, ..] if name == EFFECT_TAG && !value.is_empty()) + matches!(tag.as_slice(), [name, value] if name == EFFECT_TAG && !value.is_empty()) }) .cloned() .collect() @@ -726,12 +726,13 @@ mod mention_tests { content: "hello".to_owned(), extra_tags: vec![ vec!["effect".to_owned(), "fire".to_owned()], - Vec::new(), // malformed — dropped - vec!["effect".to_owned()], // no value — dropped - vec!["effect".to_owned(), String::new()], // empty value — dropped - vec!["e".to_owned(), "ff".repeat(32)], // reserved reply ref — dropped + Vec::new(), // malformed — dropped + vec!["effect".to_owned()], // no value — dropped + vec!["effect".to_owned(), String::new()], // empty value — dropped + vec!["effect".to_owned(), "fire".to_owned(), "x".to_owned()], // trailing field — dropped + vec!["e".to_owned(), "ff".repeat(32)], // reserved reply ref — dropped vec!["imeta".to_owned(), "url ...".to_owned()], // reserved media — dropped - vec!["stream".to_owned(), "x".to_owned()], // reserved stream — dropped + vec!["stream".to_owned(), "x".to_owned()], // reserved stream — dropped ], }; let event = build_inner_event(&intent, &valid_pubkey_hex(), 0).unwrap();