diff --git a/NOSTR.md b/NOSTR.md index 59df31b991..da503e114d 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -73,6 +73,7 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **Join request (kind:9021)** | ✅ | Open channels only. Adds member, emits system message + group discovery events + kind:44100 membership notification. Private channels rejected at ingest. | | **Edits (kind:40003)** | ⚠️ | Works on the wire but Buzz-only — no standard NIP-29 client renders these | | **Rich content (kind:40002)** | ⚠️ | Works on the wire but Buzz-only — no standard NIP-29 client renders these | +| **Surface cards (kind:40110)** | ⚠️ | Buzz-only data-only UI spec (NIP-SC); non-implementing clients ignore the kind. Edits (kind:40003) targeting a surface carry a full replacement spec. See `docs/nips/NIP-SC.md` | ### What Doesn't Work diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index e360d24982..347d46c379 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -71,6 +71,7 @@ All replies and delegations — including task assignments to other agents — g - **Never publish a bare acknowledgement.** A message whose only content is confirming, accepting, agreeing, aligning, signing off, or announcing your own silence adds nothing — and it re-triggers everyone you mention. Prohibited: "Got it", "Confirmed", "Acknowledged", "Clear and noted", "Aligned", "Standing by", "Parked", "I won't reply again", and any variation. If your draft contains nothing beyond acknowledgement, send nothing. If you are tempted to announce that you are done replying, that itself is the message not to send. - For work that requires follow-up tools, create an open todo **before** sending the pickup acknowledgment. Keep it open until the deliverable is verified and you have sent a completion or blocker message; never end a turn with open todo state unless you have posted that completion or blocker message. - Use GitHub-flavored Markdown. Fenced code blocks with language tags for syntax highlighting. +- For structured status you will keep updating (deploys, incidents, dashboards, checklists), publish a **surface card** instead of repeated messages: `buzz messages send-surface --channel --spec ` (spec: `{"version":1,"fallbackText":"...","title":"...","nodes":[...]}`; nodes: heading, text, badge, keyValue, statGrid, table, progress; tones: default/success/warning/danger/info). To notify someone on a card use `--mention ` (repeatable; card content is JSON so `@name` inside the spec is not parsed). Save the returned `event_id` and update the same card in place with `buzz messages edit-surface --event --spec ` — one card, live state, no message spam. Node shapes: heading/text `{"type":"text","text":"..."}`; badge `{"type":"badge","text":"...","tone":"..."}`; keyValue `{"type":"keyValue","items":[{"label","value","tone"?}]}`; statGrid `{"type":"statGrid","stats":[{"label","value","delta"?,"tone"?}]}`; table `{"type":"table","columns":[...],"rows":[[...]]}`; progress `{"type":"progress","label"?,"value":0-100}`. `buzz messages send-surface --help` shows full examples. - No push notifications — poll with `buzz messages get --channel --since `. - Address people by the name in their own message header. - Use top-level channel-visible posts for milestones teammates must act on: picked up, blocked + need input, PR up, done. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index dab61be30a..f86db15364 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -1235,9 +1235,7 @@ pub fn resolve_channel_filters( discovered_channels: &[Uuid], rules: &[SubscriptionRule], ) -> HashMap { - use buzz_core::kind::{ - KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED, - }; + use buzz_core::kind::{KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED}; let target_channels: Vec = if let Some(ref overrides) = config.channels_override { overrides @@ -1254,11 +1252,13 @@ pub fn resolve_channel_filters( match config.subscribe_mode { SubscribeMode::Mentions => { let kinds = config.kinds_override.clone().unwrap_or_else(|| { - vec![ - KIND_STREAM_MESSAGE, + // Conversational kinds (incl. surfaces — an agent must see a + // card it is @-mentioned in) plus the non-message kinds an + // agent is woken by. + buzz_core::kind::kinds_with(&[ KIND_WORKFLOW_APPROVAL_REQUESTED, KIND_STREAM_REMINDER, - ] + ]) }); let require_mention = !config.no_mention_filter; for ch in &target_channels { @@ -1337,9 +1337,7 @@ pub fn resolve_dynamic_channel_filter( channel_id: Uuid, rules: &[crate::filter::SubscriptionRule], ) -> Option { - use buzz_core::kind::{ - KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED, - }; + use buzz_core::kind::{KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED}; // In Mentions/All mode, if the operator explicitly constrained channels // with --channels, only allow dynamic subscription to channels in that @@ -1359,11 +1357,13 @@ pub fn resolve_dynamic_channel_filter( match config.subscribe_mode { SubscribeMode::Mentions => Some(ChannelFilter { kinds: Some(config.kinds_override.clone().unwrap_or_else(|| { - vec![ - KIND_STREAM_MESSAGE, + // Conversational kinds (incl. surfaces — an agent must see a + // card it is @-mentioned in) plus the non-message kinds an + // agent is woken by. + buzz_core::kind::kinds_with(&[ KIND_WORKFLOW_APPROVAL_REQUESTED, KIND_STREAM_REMINDER, - ] + ]) })), require_mention: !config.no_mention_filter, }), diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 403512a322..7b2e17c6d7 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1440,11 +1440,10 @@ async fn tokio_main() -> Result<()> { name: "mentions".into(), channels: filter::ChannelScope::All("all".into()), kinds: config.kinds_override.clone().unwrap_or_else(|| { - vec![ - KIND_STREAM_MESSAGE, + buzz_core::kind::kinds_with(&[ KIND_WORKFLOW_APPROVAL_REQUESTED, KIND_STREAM_REMINDER, - ] + ]) }), require_mention: !config.no_mention_filter, filter: None, diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 348bc138e4..b43d1a556b 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -2837,10 +2837,13 @@ where // #h=channel plus a sentinel, and (3) the agent's newest reply for pinning. let root_filter = nostr::Filter::new().id(nostr::EventId::from_hex(root_event_id).ok()?); let replies_filter = nostr::Filter::new() - .kinds([ - nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16), - nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE_V2 as u16), - ]) + // Conversational kinds — surfaces are context an agent must read, not + // just publish. + .kinds( + buzz_core::kind::CONVERSATIONAL_KINDS + .iter() + .map(|k| nostr::Kind::Custom(*k as u16)), + ) .custom_tags(e_tag, [root_event_id]) .custom_tags(h_tag, [ch_str.as_str()]) .limit(limit.saturating_add(1) as usize); @@ -2858,7 +2861,27 @@ where .await { Ok(Ok(json)) => { - parse_nostr_thread_response_with_meta(json, root_event_id, limit, &agent_pubkey) + // Second phase: fetch the edits that target exactly the events + // this page returned. An updated card must read as its current + // spec, and a reply's edit is addressed to the reply, not the + // thread root. Failure here degrades to original content. + let ids = collect_event_ids(&json); + let merged = if ids.is_empty() { + json + } else { + match timeout(CONTEXT_FETCH_TIMEOUT, query(edits_for_ids_filters(&ids))).await { + Ok(Ok(edits)) => merge_event_arrays(json, edits), + _ => { + tracing::warn!( + channel_id = %channel_id, + root = root_event_id, + "thread edit overlay fetch failed — rendering original content" + ); + json + } + } + }; + parse_nostr_thread_response_with_meta(merged, root_event_id, limit, &agent_pubkey) } Ok(Err(e)) => { tracing::warn!( @@ -2960,10 +2983,15 @@ async fn fetch_dm_context( let h_tag = SingleLetterTag::lowercase(Alphabet::H); let ch_str = channel_id.to_string(); let filter = nostr::Filter::new() - .kinds([ - nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16), - nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE_V2 as u16), - ]) + // Conversational kinds only — surfaces are context an agent must read, + // not just publish. Edits are fetched separately, by target id: mixing + // them into this limited filter would let edit events consume message + // slots and split an original from its update. + .kinds( + buzz_core::kind::CONVERSATIONAL_KINDS + .iter() + .map(|k| nostr::Kind::Custom(*k as u16)), + ) .custom_tags(h_tag, [ch_str.as_str()]) .limit(limit as usize); @@ -2974,7 +3002,30 @@ async fn fetch_dm_context( ) .await { - Ok(Ok(json)) => parse_nostr_dm_response(json, limit), + Ok(Ok(json)) => { + // Second phase, by target id — see `edits_for_ids_filter`. + let ids = collect_event_ids(&json); + let merged = if ids.is_empty() { + json + } else { + match timeout( + CONTEXT_FETCH_TIMEOUT, + rest.query(&edits_for_ids_filters(&ids)), + ) + .await + { + Ok(Ok(edits)) => merge_event_arrays(json, edits), + _ => { + tracing::warn!( + channel_id = %channel_id, + "DM edit overlay fetch failed — rendering original content" + ); + json + } + } + }; + parse_nostr_dm_response(merged, limit) + } Ok(Err(e)) => { tracing::warn!( channel_id = %channel_id, @@ -3067,6 +3118,109 @@ fn parse_dm_response(json: serde_json::Value, limit: u32) -> Option Vec { + json.as_array() + .map(|events| { + events + .iter() + .filter(|ev| !is_edit_event(ev)) + .filter_map(|ev| ev.get("id").and_then(|v| v.as_str()).map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +/// One filter per target for the edits addressing `ids`. +/// +/// Edits MUST be looked up by target id. Scanning a channel window instead +/// would miss a reply's edit (its `e` tag holds the reply's id, not the +/// thread root's); mixing edits into the content filter would let them consume +/// message slots. And they must be one filter PER target: a single OR-ed `#e` +/// filter shares one row budget (the relay caps a filter at 1000 rows), so a +/// heavily-edited event could hide every other target's edit. +/// +/// `limit(1)` is sufficient because the relay orders `created_at DESC, id ASC` +/// and the tie-break picks the smallest id — the first row is the winner. +fn edits_for_ids_filters(ids: &[String]) -> Vec { + let e_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::E); + ids.iter() + .map(|id| { + nostr::Filter::new() + .kind(nostr::Kind::Custom( + buzz_core::kind::KIND_STREAM_MESSAGE_EDIT as u16, + )) + .custom_tags(e_tag, [id.as_str()]) + .limit(1) + }) + .collect() +} + +/// Append `extra` events onto a relay response array. +fn merge_event_arrays(mut json: serde_json::Value, extra: serde_json::Value) -> serde_json::Value { + if let (Some(base), Some(extra)) = (json.as_array_mut(), extra.as_array()) { + base.extend(extra.iter().cloned()); + } + json +} + +/// Map of `target event id -> replacement content` from `kind:40003` edits. +/// +/// An edit carries the full replacement content for the event its `e` tag +/// points at. Without applying these, an agent reads the ORIGINAL content +/// forever — merely stale for a normal message, but wrong for a surface card, +/// whose whole update model is full-spec replacement. +/// +/// `created_at` is second-precision, so ties break on event id +/// lexicographically — the same rule desktop clients use, so agent and human +/// converge on one state. +fn latest_edits_by_target(events: &[serde_json::Value]) -> HashMap { + let mut best: HashMap = HashMap::new(); + for ev in events { + if ev.get("kind").and_then(|v| v.as_u64()) + != Some(u64::from(buzz_core::kind::KIND_STREAM_MESSAGE_EDIT)) + { + continue; + } + let Some(target) = ev.get("tags").and_then(|t| t.as_array()).and_then(|tags| { + tags.iter().find_map(|tag| { + let parts = tag.as_array()?; + (parts.first()?.as_str()? == "e") + .then(|| parts.get(1)?.as_str().map(str::to_string)) + .flatten() + }) + }) else { + continue; + }; + let created_at = ev.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); + let id = ev + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let content = ev + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let wins = best.get(&target).is_none_or(|(at, existing_id, _)| { + created_at > *at || (created_at == *at && id < *existing_id) + }); + if wins { + best.insert(target, (created_at, id, content)); + } + } + best.into_iter() + .map(|(target, (_, _, content))| (target, content)) + .collect() +} + +/// True when the event is an edit overlay rather than a message of its own. +fn is_edit_event(ev: &serde_json::Value) -> bool { + ev.get("kind").and_then(|v| v.as_u64()) + == Some(u64::from(buzz_core::kind::KIND_STREAM_MESSAGE_EDIT)) +} + /// Extract a `ContextMessage` from a JSON message object. /// /// Works with both thread reply objects and channel message objects. @@ -3132,9 +3286,17 @@ fn parse_nostr_thread_response_with_meta( let mut reply_msgs = Vec::new(); let mut seen_reply_ids = HashSet::new(); + let edits = latest_edits_by_target(events); + for ev in events { + if is_edit_event(ev) { + continue; + } let ev_id = ev.get("id").and_then(|v| v.as_str()).unwrap_or(""); - if let Some(msg) = json_to_context_message(ev) { + if let Some(mut msg) = json_to_context_message(ev) { + if let Some(content) = edits.get(ev_id) { + msg.content = content.clone(); + } if ev_id == root_event_id { root_msg = Some(msg); } else if seen_reply_ids.insert(ev_id.to_string()) { @@ -3212,11 +3374,20 @@ fn parse_nostr_thread_response_with_meta( fn parse_nostr_dm_response(json: serde_json::Value, limit: u32) -> Option { let events = json.as_array()?; + let edits = latest_edits_by_target(events); let mut messages: Vec<(u64, ContextMessage)> = events .iter() + .filter(|ev| !is_edit_event(ev)) .filter_map(|ev| { let ts = ev.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); - json_to_context_message(ev).map(|msg| (ts, msg)) + json_to_context_message(ev).map(|mut msg| { + if let Some(id) = ev.get("id").and_then(|v| v.as_str()) { + if let Some(content) = edits.get(id) { + msg.content = content.clone(); + } + } + (ts, msg) + }) }) .collect(); @@ -4583,6 +4754,9 @@ mod tests { 2, agent_pubkey, move |filters| { + if is_edits_query(&filters) { + return std::future::ready(Ok(json!([]))); + } assert_thread_query_filters(&filters, channel_id, root_id, agent_pubkey, 3); std::future::ready(Ok(json.clone())) }, @@ -4912,6 +5086,20 @@ mod tests { } } + /// True when a query is the second-phase edit lookup rather than the + /// content page (see `edits_for_ids_filter`). + fn is_edits_query(filters: &[nostr::Filter]) -> bool { + // The edit phase sends one filter PER target, so match on every filter + // being an edit filter rather than on the filter count. + !filters.is_empty() + && filters.iter().all(|filter| { + serde_json::to_value(filter) + .ok() + .and_then(|v| v.get("kinds").cloned()) + == Some(json!([buzz_core::kind::KIND_STREAM_MESSAGE_EDIT])) + }) + } + fn assert_thread_query_filters( filters: &[nostr::Filter], channel_id: Uuid, @@ -4922,7 +5110,7 @@ mod tests { assert_eq!( filters.len(), 3, - "root, recent replies, and agent reply filters" + "content phase: root, recent replies, and agent reply filters" ); let root = serde_json::to_value(&filters[0]).expect("serialize root filter"); @@ -4930,14 +5118,21 @@ mod tests { assert!(root.get("limit").is_none()); let replies = serde_json::to_value(&filters[1]).expect("serialize replies filter"); - assert_eq!(replies.get("kinds"), Some(&json!([9, 40002]))); + // Conversational kinds — surfaces are context an agent must read. + assert_eq!( + replies.get("kinds"), + Some(&json!(buzz_core::kind::CONVERSATIONAL_KINDS)) + ); assert_eq!(replies.get("#e"), Some(&json!([root_id]))); assert_eq!(replies.get("#h"), Some(&json!([channel_id.to_string()]))); assert_eq!(replies.get("limit"), Some(&json!(reply_limit))); assert!(replies.get("authors").is_none()); let agent = serde_json::to_value(&filters[2]).expect("serialize agent filter"); - assert_eq!(agent.get("kinds"), Some(&json!([9, 40002]))); + assert_eq!( + agent.get("kinds"), + Some(&json!(buzz_core::kind::CONVERSATIONAL_KINDS)) + ); assert_eq!(agent.get("#e"), Some(&json!([root_id]))); assert_eq!(agent.get("#h"), Some(&json!([channel_id.to_string()]))); assert_eq!(agent.get("authors"), Some(&json!([agent_pubkey.to_hex()]))); @@ -4948,7 +5143,10 @@ mod tests { assert_eq!(filters.len(), 1, "count should query only matching replies"); let count = serde_json::to_value(&filters[0]).expect("serialize count filter"); - assert_eq!(count.get("kinds"), Some(&json!([9, 40002]))); + assert_eq!( + count.get("kinds"), + Some(&json!(buzz_core::kind::CONVERSATIONAL_KINDS)) + ); assert_eq!(count.get("#e"), Some(&json!([root_id]))); assert_eq!(count.get("#h"), Some(&json!([channel_id.to_string()]))); assert_eq!(count.get("limit"), Some(&json!(0))); @@ -4956,6 +5154,136 @@ mod tests { assert!(count.get("authors").is_none()); } + fn edit_event(id: &str, target: &str, content: &str, created_at: u64) -> serde_json::Value { + json!({ + "id": id, + "pubkey": "editorpub", + "kind": buzz_core::kind::KIND_STREAM_MESSAGE_EDIT, + "content": content, + "created_at": created_at, + "tags": [["e", target]] + }) + } + + /// A surface card posted as a thread *reply* and later edited must read as + /// its current spec. The edit targets the REPLY id, so a channel-window + /// scan (or a `#e = root` filter) would never find it, and unrelated newer + /// edits must not displace it. + #[tokio::test] + async fn test_thread_context_applies_edit_targeting_a_reply() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let card_reply_id = "2222222222222222222222222222222222222222222222222222222222222222"; + let other_reply_id = "3333333333333333333333333333333333333333333333333333333333333333"; + let channel_id = Uuid::new_v4(); + let agent_pubkey = agent.public_key(); + + let content_page = json!([ + thread_event(root_id, "rootpub", "root", 1000), + thread_event(card_reply_id, "humanpub", "{\"original\":true}", 2000), + thread_event(other_reply_id, "humanpub", "unrelated reply", 2500), + ]); + // Newer edits for an unrelated event must not crowd out the card's own + // edit — the lookup is by target id, not by recency window. + let edits_page = json!([ + edit_event("e1", other_reply_id, "unrelated newer edit", 9000), + edit_event("e2", card_reply_id, "{\"updated\":true}", 3000), + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 50, + agent_pubkey, + move |filters| { + let payload = if is_edits_query(&filters) { + // The second phase must ask for the ids the page returned, + // including the reply that carries the card. + let targets: Vec = filters + .iter() + .filter_map(|f| { + let v = serde_json::to_value(f).ok()?; + let e = v.get("#e")?.as_array()?.first()?.as_str()?; + Some(e.to_string()) + }) + .collect(); + assert!( + targets.iter().any(|t| t == card_reply_id), + "edit lookup must target the reply carrying the card" + ); + edits_page.clone() + } else { + content_page.clone() + }; + std::future::ready(Ok(payload)) + }, + move |_filters| std::future::ready(Ok(json!({ "count": 2 }))), + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { messages, .. } => { + let contents: Vec<&str> = messages.iter().map(|m| m.content.as_str()).collect(); + assert!( + contents.contains(&"{\"updated\":true}"), + "the card reply must read as its edited spec; got {contents:?}" + ); + assert!( + !contents.contains(&"{\"original\":true}"), + "the original spec must not survive the overlay; got {contents:?}" + ); + // Three stored events in, three messages out: the two edit + // events overlay their targets instead of becoming rows. + assert_eq!( + messages.len(), + 3, + "edit events must overlay, never add rows; got {contents:?}" + ); + // Per-target overlay: the unrelated reply gets its own edit, + // not the card's. + assert!( + contents.contains(&"unrelated newer edit"), + "each edit must apply to the event it targets; got {contents:?}" + ); + } + other => panic!("expected Thread context, got {other:?}"), + } + } + + /// Edits are fetched separately from the content page, so they must never + /// consume message slots nor appear as messages of their own. + #[test] + fn test_dm_context_edits_do_not_consume_message_slots() { + let target = "4444444444444444444444444444444444444444444444444444444444444444"; + let merged = json!([ + thread_event(target, "humanpub", "{\"original\":true}", 1000), + thread_event( + "5555555555555555555555555555555555555555555555555555555555555555", + "humanpub", + "second message", + 1100 + ), + edit_event("e9", target, "{\"updated\":true}", 1200), + ]); + + let ctx = parse_nostr_dm_response(merged, 2).expect("dm context"); + match ctx { + ConversationContext::Dm { messages, .. } => { + assert_eq!( + messages.len(), + 2, + "the edit must not occupy a message slot: {messages:?}" + ); + assert!( + messages.iter().any(|m| m.content == "{\"updated\":true}"), + "the edited message must read as its current content: {messages:?}" + ); + } + other => panic!("expected Dm context, got {other:?}"), + } + } + fn thread_event(id: &str, pubkey: &str, content: &str, created_at: u64) -> serde_json::Value { json!({ "id": id, diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index b1a9372ea4..23fff1b47b 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -36,7 +36,7 @@ use std::collections::HashSet; use anyhow::Result; use buzz_core::kind::{ - KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE, + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_WORKFLOW_APPROVAL_REQUESTED, }; use nostr::EventId; @@ -410,7 +410,9 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> } // Ignore non-message kinds (relay housekeeping, etc.). - if kind_u32 != KIND_STREAM_MESSAGE && kind_u32 != KIND_WORKFLOW_APPROVAL_REQUESTED { + if !buzz_core::kind::CONVERSATIONAL_KINDS.contains(&kind_u32) + && kind_u32 != KIND_WORKFLOW_APPROVAL_REQUESTED + { continue; } @@ -524,7 +526,7 @@ fn build_setup_subscription_rules(config: &Config) -> Vec # Diffs buzz messages send-diff --channel --diff - --repo https://github.com/org/repo --commit abc123 < diff.patch +# Surface cards (data-only UI specs rendered as native cards) +buzz messages send-surface --channel --spec ./card.json +buzz messages send-surface --channel --spec ./card.json --mention alice --mention +buzz messages edit-surface --event --spec ./card-v2.json # live update, full replacement + # Channels buzz channels list buzz channels create --name "my-channel" --type stream --visibility open @@ -104,6 +109,8 @@ stored rules in `validation_error` so an owner can remove and repair them. |-------|-----------|-------------| | `messages` | `send` | Send a message to a channel | | | `send-diff` | Send a code diff with metadata | +| | `send-surface` | Publish a surface card (SurfaceSpec v1 JSON) | +| | `edit-surface` | Replace a surface card's spec in place | | | `edit` | Edit a message you sent | | | `delete` | Delete a message | | | `get` | List messages in a channel | @@ -182,3 +189,50 @@ stdout: raw relay JSON stderr: {"error": "category", "message": "detail"} exit: 0=ok 1=user 2=network 3=auth 4=other 5=write conflict ``` + +## Surface cards + +A surface card is a signed event whose content is a small JSON spec the client +renders as a native card — no markup, links, or scripts; you control content, +never layout. Editing replaces the whole spec in place, so one card can track +a changing process (healthy → incident → recovered) inside a single message. + +```bash +buzz messages send-surface --channel --spec - <<'JSON' +{ + "version": 1, + "fallbackText": "Deploy v2.4.1: 2/2 pods running, rollout 100%", + "title": "Deployment — api-gateway", + "nodes": [ + {"type": "badge", "text": "HEALTHY", "tone": "success"}, + {"type": "keyValue", "items": [{"label": "Version", "value": "v2.4.1", "tone": "info"}]}, + {"type": "statGrid", "stats": [{"label": "Pods", "value": 2, "tone": "success"}, {"label": "Errors", "value": 0}]}, + {"type": "table", "columns": ["Pod", "Status"], "rows": [["web-7d9f", "Running"], ["web-a1c2", "Running"]]}, + {"type": "progress", "label": "Rollout", "value": 100} + ] +} +JSON +``` + +Nodes: `heading` (section heading), `text` (plain paragraph), `badge` (status +pill), `keyValue` (label/value list), `statGrid` (stat tiles with optional +`delta`), `table` (`columns` + `rows`), `progress` (0–100 bar). Tones +everywhere: `default | success | warning | danger | info`. + +Surface content is JSON, so there is no `@name` text to parse — mention people +explicitly with repeatable `--mention` (pubkey hex, npub, or display name). +Mentions must be current channel members; the response echoes the resolved +`mention_pubkeys` so you can verify delivery rather than assume it. Mentioned +users get the card in their Home feed, unread state, and desktop notifications +while the app is open. Background push (NIP-PL) does not carry surfaces yet. + +**Save the returned `event_id`** — it is required to update the card: + +```bash +buzz messages edit-surface --event --spec ./card-incident.json +``` + +Limits: ≤32 nodes, ≤32 KiB canonical JSON, tables ≤12×100, text ≤4096 chars, +labels/values ≤512 chars. Validation errors name the exact field +(`nodes[3].table: 13 columns exceeds max 12`) — fix and resend, no relay +round-trip wasted. Only the surface author can edit it. diff --git a/crates/buzz-cli/src/commands/feed.rs b/crates/buzz-cli/src/commands/feed.rs index d3d5c7f81a..0cbf38653d 100644 --- a/crates/buzz-cli/src/commands/feed.rs +++ b/crates/buzz-cli/src/commands/feed.rs @@ -40,6 +40,10 @@ pub async fn cmd_get_feed( let resp = client.query(&filter).await?; let mut events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + // An edit carries no `p` tag, so a mentioned card would otherwise stay on + // the spec it was first published with — including on startup recovery, + // which is exactly when an agent needs current state. + crate::commands::messages::overlay_latest_edits(client, &mut events).await; events.sort_by_key(|e| Reverse(e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0))); let normalized = normalize_events(&events); let output = match format { diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 40a9ae80b5..ed577d0fd2 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -350,6 +350,194 @@ fn format_events(normalized: &str, format: &crate::OutputFormat) -> String { } } +/// The `e` tag an edit addresses, if any. +fn edit_target_id(event: &serde_json::Value) -> Option { + event + .get("tags") + .and_then(|t| t.as_array()) + .and_then(|tags| { + tags.iter().find_map(|tag| { + let parts = tag.as_array()?; + (parts.first()?.as_str()? == "e") + .then(|| parts.get(1)?.as_str().map(str::to_string)) + .flatten() + }) + }) +} + +/// Overlay the latest `kind:40003` edit content onto the events it targets, +/// and report edits that landed in the polling window but target something +/// outside this page. +/// +/// Agents poll with `messages get` / `messages thread`, so those must report +/// the message's CURRENT content — a surface card's whole update model is a +/// full-spec replacement, and an agent reading the original spec forever would +/// act on stale state. +/// +/// Edits are looked up **by target id**: an edit tags the event it replaces, +/// so a reply's edit does not carry the thread root's id, and a window scan +/// could miss it entirely. Ties on `created_at` (second precision) break on +/// event id, matching every other client so all readers converge. +/// +/// When `since` is given, a second pass also pulls edits made inside that +/// window whose target is NOT on this page — a card published earlier and +/// updated now produces only an edit event, and a polling reader that never +/// saw it would keep acting on stale state. Those edits are appended as their +/// own rows; edits folded into a target on this page are not, since they would +/// duplicate it. +/// +/// Best-effort: a failed lookup leaves original content rather than failing +/// the read. +pub(crate) async fn overlay_latest_edits(client: &BuzzClient, events: &mut Vec) { + overlay_latest_edits_in_window(client, events, None, None).await +} + +/// [`overlay_latest_edits`] plus out-of-page edit reporting for a polling +/// window (`channel_id` + `since`). +pub(crate) async fn overlay_latest_edits_in_window( + client: &BuzzClient, + events: &mut Vec, + channel_id: Option<&str>, + since: Option, +) { + const EDIT_KIND: u64 = 40003; + // One filter per target: a single OR-ed `#e` filter shares one row budget + // (the relay caps a filter at 1000 rows), so one heavily-edited event could + // hide every other target's edit. One row each suffices — the relay orders + // `created_at DESC, id ASC` and the tie-break picks the smallest id, so the + // first row is the winner. + const FILTERS_PER_QUERY: usize = 25; + const ROWS_PER_TARGET: usize = 1; + + let target_ids: Vec = events + .iter() + .filter(|e| e.get("kind").and_then(|v| v.as_u64()) != Some(EDIT_KIND)) + .filter_map(|e| e.get("id").and_then(|v| v.as_str()).map(str::to_string)) + .collect(); + + // NOTE: no early return on an empty page — the window pass below is what + // tells a polling reader that a card published earlier just changed, and a + // window can legitimately contain only that edit. + let mut edits: Vec = Vec::new(); + for chunk in target_ids.chunks(FILTERS_PER_QUERY) { + let filters: Vec = chunk + .iter() + .map(|id| { + serde_json::json!({ + "kinds": [EDIT_KIND], + "#e": [id], + "limit": ROWS_PER_TARGET, + }) + }) + .collect(); + let Ok(raw) = client.query_multi(&filters).await else { + continue; + }; + edits.extend(serde_json::from_str::>(&raw).unwrap_or_default()); + } + + // target id -> (created_at, edit id, content) + let mut latest: std::collections::HashMap = + std::collections::HashMap::new(); + for edit in &edits { + let Some(target) = edit + .get("tags") + .and_then(|t| t.as_array()) + .and_then(|tags| { + tags.iter().find_map(|tag| { + let parts = tag.as_array()?; + (parts.first()?.as_str()? == "e") + .then(|| parts.get(1)?.as_str().map(str::to_string)) + .flatten() + }) + }) + else { + continue; + }; + let created_at = edit.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); + let id = edit + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let content = edit + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let wins = latest.get(&target).is_none_or(|(at, existing_id, _)| { + created_at > *at || (created_at == *at && id < *existing_id) + }); + if wins { + latest.insert(target, (created_at, id, content)); + } + } + + let mut applied: std::collections::HashSet = std::collections::HashSet::new(); + for event in events.iter_mut() { + if event.get("kind").and_then(|v| v.as_u64()) == Some(EDIT_KIND) { + continue; + } + let Some(id) = event.get("id").and_then(|v| v.as_str()).map(str::to_string) else { + continue; + }; + if let Some((_, _, content)) = latest.get(&id) { + if let Some(obj) = event.as_object_mut() { + obj.insert("content".into(), serde_json::json!(content)); + } + applied.insert(id); + } + } + + // Edits made inside the polling window whose target is NOT on this page: + // the only signal a reader gets that something published earlier changed. + // Only the winning edit per target is reported — a card updated 60 times + // is one changed thing, not 60 rows of spec JSON. + if let (Some(channel_id), Some(since)) = (channel_id, since) { + let filter = serde_json::json!({ + "kinds": [EDIT_KIND], + "#h": [channel_id], + "since": since, + }); + if let Ok(raw) = client.query(&filter).await { + let window_edits: Vec = + serde_json::from_str(&raw).unwrap_or_default(); + let page_ids: std::collections::HashSet = target_ids.iter().cloned().collect(); + let mut winners: std::collections::HashMap = + std::collections::HashMap::new(); + for edit in window_edits { + let Some(target) = edit_target_id(&edit) else { + continue; + }; + if page_ids.contains(&target) { + continue; + } + let created_at = edit.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); + let id = edit.get("id").and_then(|v| v.as_str()).unwrap_or_default(); + let wins = winners.get(&target).is_none_or(|existing| { + let at = existing + .get("created_at") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let existing_id = existing.get("id").and_then(|v| v.as_str()).unwrap_or(""); + created_at > at || (created_at == at && id < existing_id) + }); + if wins { + winners.insert(target, edit); + } + } + events.extend(winners.into_values()); + } + } + + events.retain(|event| { + if event.get("kind").and_then(|v| v.as_u64()) != Some(EDIT_KIND) { + return true; + } + edit_target_id(event).is_none_or(|target| !applied.contains(&target)) + }); +} + pub async fn cmd_get_messages( client: &BuzzClient, channel_id: &str, @@ -362,8 +550,12 @@ pub async fn cmd_get_messages( validate_uuid(channel_id)?; let limit = limit.unwrap_or(50).min(200); + // Content kinds only. Edits are fetched separately (see + // `overlay_latest_edits`): sharing this limit with kind:40003 would let a + // heavily-edited card fill the page with raw edit rows and return no + // messages at all. let mut filter = serde_json::json!({ - "kinds": [9, 40002, 40008, 45001, 45003], + "kinds": [9, 40002, 40008, 40110, 45001, 45003], "#h": [channel_id], "limit": limit }); @@ -386,6 +578,7 @@ pub async fn cmd_get_messages( let resp = client.query(&filter).await?; let mut events: Vec = serde_json::from_str(&resp).unwrap_or_default(); events.sort_by_key(|e| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)); + overlay_latest_edits_in_window(client, &mut events, Some(channel_id), since).await; let normalized = normalize_events(&events); println!("{}", format_events(&normalized, format)); Ok(()) @@ -406,8 +599,11 @@ pub async fn cmd_get_thread( // Two filters ORed in a single HTTP call: // 1. Replies referencing this event via e-tag (no kind restriction) // 2. The root event itself by ID + // Content kinds only — edits are resolved by target id in the overlay. If + // they shared this budget, a root edited 100+ times would fill the page + // with edit rows and drop every real reply. let mut reply_filter = serde_json::json!({ - "kinds": [9, 40002, 40003, 40008, 45003], + "kinds": [9, 40002, 40008, 40110, 45003], "#h": [channel_id], "#e": [event_id], "limit": limit @@ -422,6 +618,9 @@ pub async fn cmd_get_thread( let resp = client.query_multi(&[reply_filter, root_filter]).await?; let mut events: Vec = serde_json::from_str(&resp).unwrap_or_default(); events.sort_by_key(|e| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)); + // The reply filter only catches edits tagged to the ROOT; an edited reply + // carries its own id, so resolve current content by target id. + overlay_latest_edits(client, &mut events).await; let normalized = normalize_events(&events); println!("{}", format_events(&normalized, format)); Ok(()) @@ -834,6 +1033,238 @@ pub async fn cmd_edit_message( Ok(()) } +/// Resolve `--mention` values for a surface against the channel's membership. +/// +/// Surface content is canonical JSON, so there is no `@name` text to parse — +/// mentions are explicit. They still get the same guarantees the normal send +/// path gives: a mention must be a *current channel member* (a `p` tag for a +/// non-member is a notification the recipient can never read), values are +/// deduplicated, and the total is capped at [`MENTION_CAP`]. +/// +/// Accepts pubkey hex, npub, or a member's display name. +async fn resolve_surface_mentions( + client: &BuzzClient, + channel_id: &str, + mentions: &[String], +) -> Result, CliError> { + if mentions.is_empty() { + return Ok(vec![]); + } + + let members_filter = serde_json::json!({ + "kinds": [39002], + "#d": [channel_id], + "limit": 1, + }); + let member_pubkeys = fetch_member_pubkeys(client, &members_filter) + .await + .ok_or_else(|| { + CliError::Other("could not load channel membership for mention preflight".into()) + })?; + + // Display-name lookup, built from the members' own profiles. + let profiles_filter = serde_json::json!({ + "kinds": [0], + "authors": member_pubkeys, + "limit": member_pubkeys.len(), + }); + let profile_events = fetch_events(client, &profiles_filter) + .await + .unwrap_or_default(); + let mut name_to_pubkeys: std::collections::HashMap> = + std::collections::HashMap::new(); + for e in &profile_events { + let (Some(pubkey), Some(content_json)) = ( + e.get("pubkey").and_then(|v| v.as_str()), + e.get("content").and_then(|v| v.as_str()), + ) else { + continue; + }; + let Ok(v) = serde_json::from_str::(content_json) else { + continue; + }; + if let Some(name) = v + .get("display_name") + .or_else(|| v.get("name")) + .and_then(|n| n.as_str()) + .filter(|n| !n.is_empty()) + { + name_to_pubkeys + .entry(name.to_ascii_lowercase()) + .or_default() + .push(pubkey.to_string()); + } + } + + let mut resolved: Vec = Vec::new(); + for value in mentions { + let raw = value.trim(); + let hex = match PublicKey::parse(raw) { + Ok(pubkey) => pubkey.to_hex(), + Err(_) => match name_to_pubkeys + .get(&raw.to_ascii_lowercase()) + .map(Vec::as_slice) + .unwrap_or_default() + { + [pubkey] => pubkey.clone(), + [] => { + return Err(CliError::Usage(format!( + "--mention '{raw}' does not match a current channel member; \ + retry with --mention " + ))) + } + candidates => { + return Err(CliError::Usage(format!( + "--mention '{raw}' is ambiguous; candidates: {}. \ + Retry with --mention ", + candidates.join(", ") + ))) + } + }, + }; + if !member_pubkeys.contains(&hex) { + return Err(CliError::Usage(format!( + "--mention {hex} is not a member of this channel; \ + a p tag for a non-member cannot be delivered" + ))); + } + if !resolved.contains(&hex) { + resolved.push(hex); + } + } + + if resolved.len() > MENTION_CAP { + return Err(CliError::Usage(format!( + "too many --mention values (max {MENTION_CAP})" + ))); + } + Ok(resolved) +} + +/// Read a `--spec` argument: `-` for stdin, an existing file path, or inline JSON. +fn read_spec_arg(value: &str) -> Result { + if value == "-" { + return read_or_stdin(value); + } + let trimmed = value.trim_start(); + if trimmed.starts_with('{') { + return Ok(value.to_string()); + } + // Anything else that still looks like inline JSON (array, quoted string) + // is a shape mistake, not a file path — say so instead of ENOENT noise. + if trimmed.starts_with('[') || trimmed.starts_with('"') { + return Err(CliError::Usage( + "--spec: inline spec must be a JSON object like {\"version\":1,\"fallbackText\":\"...\",\"nodes\":[...]}".into(), + )); + } + std::fs::read_to_string(value) + .map_err(|e| CliError::Usage(format!("--spec: cannot read file {value}: {e}"))) +} + +/// Parse, alias-normalize, and validate a SurfaceSpec v1 JSON document. +/// +/// Errors are field-specific (`nodes[3].table: 14 columns exceeds max 12`) so +/// the payload can be fixed without a relay round-trip. +fn parse_surface_spec(raw: &str) -> Result { + // Syntax errors come from the FIRST parse so line/column point into the + // user's own file. Schema errors are decoded from the parsed value — + // serde reports field paths there, never positions in some internal + // re-serialization the user has no way to line up with their input. + let mut value: serde_json::Value = serde_json::from_str(raw) + .map_err(|e| CliError::Usage(format!("--spec is not valid JSON: {e}")))?; + buzz_core::surface::normalize_spec_aliases(&mut value); + let spec: buzz_core::surface::SurfaceSpecV1 = serde_json::from_value(value) + .map_err(|e| CliError::Usage(format!("invalid surface spec: {e}")))?; + spec.validate() + .map_err(|e| CliError::Usage(format!("invalid surface spec: {e}")))?; + Ok(spec) +} + +/// Publish a surface card to a channel. +pub async fn cmd_send_surface( + client: &BuzzClient, + channel_id: &str, + spec_arg: &str, + reply_to: Option<&str>, + mentions: &[String], +) -> Result<(), CliError> { + let channel_uuid = parse_uuid(channel_id)?; + if let Some(r) = &reply_to { + validate_hex64(r)?; + } + let spec = parse_surface_spec(&read_spec_arg(spec_arg)?)?; + let thread_ref = match reply_to { + Some(r) => Some(resolve_thread_ref(client, r).await?), + None => None, + }; + let mention_pubkeys = resolve_surface_mentions(client, channel_id, mentions).await?; + let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect(); + + let builder = buzz_sdk::build_surface(channel_uuid, &spec, thread_ref.as_ref(), &mention_refs) + .map_err(crate::validate::sdk_err)?; + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + + // Report who was actually p-tagged so the caller (usually an agent) can + // verify delivery instead of assuming it. + let mut out: serde_json::Value = serde_json::from_str(&normalize_write_response(&resp)) + .unwrap_or_else(|_| serde_json::json!({})); + if let Some(obj) = out.as_object_mut() { + obj.insert("mention_pubkeys".into(), serde_json::json!(mention_pubkeys)); + } + println!("{out}"); + Ok(()) +} + +/// Replace a surface card's spec in place (live update via the edit kind). +pub async fn cmd_edit_surface( + client: &BuzzClient, + event_id: &str, + spec_arg: &str, +) -> Result<(), CliError> { + validate_hex64(event_id)?; + let spec = parse_surface_spec(&read_spec_arg(spec_arg)?)?; + + // Resolve the target and verify it is a surface before signing anything. + let filter = serde_json::json!({ "ids": [event_id], "limit": 1 }); + let raw = client.query(&filter).await?; + let events: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("failed to parse query response: {e}")))?; + let event = events + .as_array() + .and_then(|a| a.first()) + .ok_or_else(|| CliError::Other(format!("event {event_id} not found")))?; + let kind = event.get("kind").and_then(|k| k.as_u64()).unwrap_or(0); + if kind != u64::from(buzz_core::kind::KIND_SURFACE) { + return Err(CliError::Usage(format!( + "event {event_id} is kind {kind}, not a surface (kind {}) — use 'messages edit' for regular messages", + buzz_core::kind::KIND_SURFACE + ))); + } + let channel_uuid = event + .get("tags") + .and_then(|t| t.as_array()) + .and_then(|tags| { + tags.iter().find_map(|tag| { + let arr = tag.as_array()?; + if arr.first()?.as_str()? == "h" { + Uuid::parse_str(arr.get(1)?.as_str()?).ok() + } else { + None + } + }) + }) + .ok_or_else(|| CliError::Other("surface event has no valid h tag".into()))?; + + let target_eid = parse_event_id(event_id)?; + let builder = buzz_sdk::build_surface_edit(channel_uuid, target_eid, &spec) + .map_err(crate::validate::sdk_err)?; + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + /// Vote on a forum post or comment. pub async fn cmd_vote_on_post( client: &BuzzClient, @@ -929,6 +1360,13 @@ pub async fn dispatch( .await } MessagesCmd::Edit { event, content } => cmd_edit_message(client, &event, &content).await, + MessagesCmd::SendSurface { + channel, + spec, + reply_to, + mention, + } => cmd_send_surface(client, &channel, &spec, reply_to.as_deref(), &mention).await, + MessagesCmd::EditSurface { event, spec } => cmd_edit_surface(client, &event, &spec).await, MessagesCmd::Delete { event, action_id, diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0726406d29..5498d2a14c 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -421,6 +421,37 @@ pub enum MessagesCmd { #[arg(long)] content: String, }, + /// Publish a surface card — a versioned, data-only UI spec rendered as a native card + #[command( + after_help = "The spec is SurfaceSpec v1 JSON: {\"version\":1,\"fallbackText\":\"...\",\"title\":\"...\",\"nodes\":[...]}.\n\nNode shapes (tone is optional: default|success|warning|danger|info):\n {\"type\":\"heading\",\"text\":\"...\"}\n {\"type\":\"text\",\"text\":\"...\"} (plain text, max 4096 chars)\n {\"type\":\"badge\",\"text\":\"...\",\"tone\":\"success\"}\n {\"type\":\"keyValue\",\"items\":[{\"label\":\"...\",\"value\":\"...\",\"tone\":\"info\"}]}\n {\"type\":\"statGrid\",\"stats\":[{\"label\":\"...\",\"value\":2,\"delta\":\"+1\",\"tone\":\"success\"}]}\n {\"type\":\"table\",\"columns\":[\"A\",\"B\"],\"rows\":[[\"a1\",\"b1\"]]} (max 12x100, rows match columns)\n {\"type\":\"progress\",\"label\":\"...\",\"value\":80} (0-100)\nValues/cells are strings or numbers. Limits: 1-32 nodes, 32 KiB total.\nThe response event_id is REQUIRED to update the card later via 'messages edit-surface'.\n\nExamples:\n buzz messages send-surface --channel --spec ./card.json\n echo '{\"version\":1,...}' | buzz messages send-surface --channel --spec -" + )] + SendSurface { + /// Channel UUID + #[arg(long)] + channel: String, + /// SurfaceSpec v1 JSON — a file path, '-' for stdin, or inline JSON + #[arg(long)] + spec: String, + /// Event ID to reply to (surface as a thread reply) + #[arg(long)] + reply_to: Option, + /// Mention someone on the card — pubkey hex, npub, or display name. + /// Repeatable. Surface content is JSON, so mentions are explicit. + #[arg(long = "mention")] + mention: Vec, + }, + /// Replace a surface card's spec in place (live update — full-spec replacement) + #[command( + after_help = "Replaces the whole spec; the card updates in place for everyone.\nOnly the surface author can edit it.\n\nExamples:\n buzz messages edit-surface --event --spec ./card-v2.json" + )] + EditSurface { + /// Event ID of the surface to update (from send-surface output) + #[arg(long)] + event: String, + /// Replacement SurfaceSpec v1 JSON — a file path, '-' for stdin, or inline JSON + #[arg(long)] + spec: String, + }, /// Delete a message by event ID Delete { /// Event ID to delete (64-char hex) @@ -1945,10 +1976,12 @@ mod tests { vec![ "delete", "edit", + "edit-surface", "get", "search", "send", "send-diff", + "send-surface", "thread", "vote" ] @@ -2071,7 +2104,7 @@ mod tests { ("feed", 1), ("issues", 4), ("media", 1), - ("messages", 8), + ("messages", 10), ("pack", 2), ("patches", 4), ("pr", 5), diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index b1be7c5038..d0e4637c86 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -480,6 +480,51 @@ pub const KIND_STREAM_REMINDER: u32 = 40007; pub const KIND_STREAM_MESSAGE_DIFF: u32 = 40008; /// Canvas (shared document) for a channel. pub const KIND_CANVAS: u32 = 40100; +/// Surface Card — versioned, data-only UI spec rendered as a native card. +/// +/// Content is canonical `SurfaceSpec v1` JSON (see [`crate::surface`]). +/// Regular, stored, channel-scoped like stream messages; live updates reuse +/// [`KIND_STREAM_MESSAGE_EDIT`] with full-spec replacement. See +/// `docs/nips/NIP-SC.md`. +/// +/// Number pending maintainer assignment on block/buzz#2480 — 40110 proposed +/// (free, adjacent to canvas at 40100). +pub const KIND_SURFACE: u32 = 40110; + +/// Kinds that are a **human-visible message in a channel timeline** — the +/// conversational content set. +/// +/// Every read path that asks "is this a message someone wrote?" must use this +/// rather than spelling out `[KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2]`: +/// mention/Home feeds, thread-parent resolution, channel recency, agent +/// mention subscriptions, and agent context fetches. A kind that renders as a +/// timeline row but is *not* conversational (system rows, job lifecycle, +/// huddle cards) stays out. +/// +/// Sites usually need this set **plus** something local (forum kinds, git +/// kinds, huddle cards); compose with [`kinds_with`] rather than re-listing +/// the conversational kinds, so a new content kind lands everywhere at once. +/// +/// Mirrored by `CHANNEL_MESSAGE_EVENT_KINDS` in +/// `desktop/src/shared/constants/kinds.ts` (parity is asserted on both sides). +pub const CONVERSATIONAL_KINDS: &[u32] = + &[KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, KIND_SURFACE]; + +/// [`CONVERSATIONAL_KINDS`] plus the caller's own kinds, deduplicated and +/// order-stable (conversational kinds first). +/// +/// Use at every site that needs the conversational set widened with something +/// local — it keeps the shared part in one place while the local part stays +/// visible at the call site. +pub fn kinds_with(extra: &[u32]) -> Vec { + let mut out: Vec = CONVERSATIONAL_KINDS.to_vec(); + for kind in extra { + if !out.contains(kind) { + out.push(*kind); + } + } + out +} /// System message for channel state changes (join, leave, rename, etc.). pub const KIND_SYSTEM_MESSAGE: u32 = 40099; @@ -694,6 +739,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_STREAM_REMINDER, KIND_STREAM_MESSAGE_DIFF, KIND_CANVAS, + KIND_SURFACE, KIND_SYSTEM_MESSAGE, KIND_CHANNEL_SUMMARY, KIND_PRESENCE_SNAPSHOT, @@ -877,6 +923,10 @@ const _: () = assert!(KIND_REPORT <= u16::MAX as u32); const _: () = assert!(KIND_MODERATION_RESOLVE_REPORT <= u16::MAX as u32); const _: () = assert!(!is_ephemeral(KIND_REPORT)); const _: () = assert!(is_moderation_command_kind(KIND_MODERATION_BAN)); +// KIND_SURFACE is cast to u16 for nostr::Kind::Custom at every producer — +// keep any future renumbering (pending maintainer assignment on #2480) +// inside the encodable range, at compile time. +const _: () = assert!(KIND_SURFACE <= u16::MAX as u32); const _: () = assert!(is_moderation_command_kind(KIND_MODERATION_RESOLVE_REPORT)); const _: () = assert!(!is_moderation_command_kind(KIND_REPORT)); @@ -884,6 +934,49 @@ const _: () = assert!(!is_moderation_command_kind(KIND_REPORT)); mod tests { use super::*; + #[test] + fn conversational_kinds_include_every_channel_message_kind() { + // Surfaces are conversational content: every read path that treats + // kind:9 as "a message someone wrote" must treat 40110 the same way. + // Mirrors CHANNEL_MESSAGE_EVENT_KINDS in desktop's kinds.ts (parity is + // asserted there too). + for kind in [KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, KIND_SURFACE] { + assert!( + CONVERSATIONAL_KINDS.contains(&kind), + "kind {kind} must be in CONVERSATIONAL_KINDS" + ); + } + } + + #[test] + fn conversational_kinds_exclude_non_conversational_rows() { + // Rows that render in a timeline but are not something a person wrote. + for kind in [ + KIND_SYSTEM_MESSAGE, + KIND_HUDDLE_STARTED, + KIND_JOB_REQUEST, + KIND_STREAM_MESSAGE_EDIT, + KIND_REACTION, + ] { + assert!( + !CONVERSATIONAL_KINDS.contains(&kind), + "kind {kind} must NOT be in CONVERSATIONAL_KINDS" + ); + } + } + + #[test] + fn kinds_with_prepends_conversational_and_dedupes() { + let out = kinds_with(&[KIND_FORUM_POST, KIND_STREAM_MESSAGE]); + assert_eq!(&out[..CONVERSATIONAL_KINDS.len()], CONVERSATIONAL_KINDS); + assert!(out.contains(&KIND_FORUM_POST)); + assert_eq!( + out.iter().filter(|k| **k == KIND_STREAM_MESSAGE).count(), + 1, + "a kind already in the conversational set must not be duplicated" + ); + } + #[test] fn no_duplicate_kind_values() { let mut seen = std::collections::HashSet::new(); diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 66b7708f1d..bd42bf8312 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -34,6 +34,9 @@ pub mod pairing; pub mod presence; /// Canonical relay runtime identities. pub mod relay; +/// Surface Cards — versioned data-only UI spec: payload types, validation, +/// canonicalization, and producer-side alias normalization. +pub mod surface; /// Tenant identity — the server-resolved community key carried on scoped paths. pub mod tenant; /// Schnorr signature and event ID verification. diff --git a/crates/buzz-core/src/surface.rs b/crates/buzz-core/src/surface.rs new file mode 100644 index 0000000000..7df0ffbadd --- /dev/null +++ b/crates/buzz-core/src/surface.rs @@ -0,0 +1,672 @@ +//! Surface Cards — versioned, data-only UI spec rendered as a native card. +//! +//! A surface event's content is a small JSON document (`SurfaceSpec v1`): +//! `version`, `fallbackText`, optional `title`, and an ordered list of +//! display nodes (heading, text, badge, keyValue, statGrid, table, progress). +//! The author controls content, never layout or styling — the spec carries no +//! markup, links, or scripts. Live updates reuse the existing edit kind +//! (`KIND_STREAM_MESSAGE_EDIT`) with full-spec replacement. +//! +//! The relay is the strict gate: [`parse_and_validate`] rejects unknown +//! fields, unknown node types, unknown tones, non-scalar cell values, and any +//! structural-limit violation. Clients parse tolerantly (drop invalid nodes, +//! fall back to `fallbackText`) for historical/foreign events — that +//! tolerance lives client-side, not here. +//! +//! Producers (SDK/CLI) normalize known field aliases via +//! [`normalize_spec_aliases`] and serialize canonically *before* signing, so +//! stored specs are canonical. See block/buzz#2480 for the design discussion. + +use serde::{Deserialize, Serialize}; + +/// Maximum canonical JSON content size in bytes (32 KiB). +/// +/// The relay WebSocket frame limit is 64 KiB for the whole event envelope +/// (tags + sig included), so a 64 KiB content cap could never fit — the diff +/// kind uses 60 KiB for the same reason. Surfaces are far smaller; 32 KiB +/// bounds worst-case render work while leaving generous headroom for +/// data-heavy tables. +pub const SURFACE_MAX_CONTENT_BYTES: usize = 32 * 1024; +/// Maximum number of nodes in a spec. +pub const SURFACE_MAX_NODES: usize = 32; +/// Maximum `title` length in characters. +pub const SURFACE_MAX_TITLE_CHARS: usize = 256; +/// Maximum `fallbackText` length in characters. +pub const SURFACE_MAX_FALLBACK_CHARS: usize = 512; +/// Maximum `text` node body length in characters. +pub const SURFACE_MAX_TEXT_CHARS: usize = 4096; +/// Maximum length of any label, value, cell, or delta in characters. +pub const SURFACE_MAX_SCALAR_CHARS: usize = 512; +/// Maximum `keyValue.items` / `statGrid.stats` entries per node. +pub const SURFACE_MAX_ITEMS: usize = 32; +/// Maximum table columns. +pub const SURFACE_MAX_TABLE_COLUMNS: usize = 12; +/// Maximum table rows. +pub const SURFACE_MAX_TABLE_ROWS: usize = 100; +/// The only supported spec version. +pub const SURFACE_SPEC_VERSION: u32 = 1; + +/// Validation failure with a field-specific message. +/// +/// The message names the offending field (e.g. `nodes[3].table: 14 columns +/// exceeds max 12`) so producers — human or agent — can fix the payload +/// without a relay round-trip. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SurfaceError(String); + +impl SurfaceError { + fn new(msg: impl Into) -> Self { + Self(msg.into()) + } +} + +impl std::fmt::Display for SurfaceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for SurfaceError {} + +/// Semantic tone applied to badges, key-value entries, and stats. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SurfaceTone { + /// Neutral (the default when omitted). + #[default] + Default, + /// Positive / healthy. + Success, + /// Needs attention. + Warning, + /// Failing / destructive. + Danger, + /// Informational. + Info, +} + +/// A scalar cell value: a string or a finite JSON number. +/// +/// Booleans, objects, arrays, and null are rejected by construction — the +/// untagged enum only admits these two shapes. `serde_json::Number` preserves +/// the author's integer/decimal representation through canonicalization. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SurfaceValue { + /// Text value. + Text(String), + /// Finite numeric value. + Number(serde_json::Number), +} + +impl SurfaceValue { + /// Character length of the value as displayed. + fn display_len(&self) -> usize { + match self { + Self::Text(s) => s.chars().count(), + Self::Number(n) => n.to_string().chars().count(), + } + } + + fn check_finite(&self, field: &str) -> Result<(), SurfaceError> { + if let Self::Number(n) = self { + match n.as_f64() { + Some(v) if v.is_finite() => {} + _ => return Err(SurfaceError::new(format!("{field}: number must be finite"))), + } + } + Ok(()) + } +} + +/// One `keyValue` entry. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceKeyValueItem { + /// Entry label. + pub label: String, + /// Entry value. + pub value: SurfaceValue, + /// Semantic tone (defaults to neutral). + #[serde(default, skip_serializing_if = "is_default_tone")] + pub tone: SurfaceTone, +} + +/// One `statGrid` entry. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceStatItem { + /// Stat label. + pub label: String, + /// Stat value. + pub value: SurfaceValue, + /// Optional delta shown under the value (e.g. `"+8%"`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delta: Option, + /// Semantic tone (defaults to neutral). + #[serde(default, skip_serializing_if = "is_default_tone")] + pub tone: SurfaceTone, +} + +fn is_default_tone(tone: &SurfaceTone) -> bool { + *tone == SurfaceTone::Default +} + +/// A single display node. Rendered in order; v1 nodes are display-only. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", deny_unknown_fields)] +pub enum SurfaceNode { + /// Section heading. + #[serde(rename = "heading")] + Heading { + /// Heading text. + text: String, + }, + /// Free-form paragraph (plain text — never markdown). + #[serde(rename = "text")] + Text { + /// Paragraph body. + text: String, + }, + /// Small status pill. + #[serde(rename = "badge")] + Badge { + /// Badge text. + text: String, + /// Semantic tone (defaults to neutral). + #[serde(default, skip_serializing_if = "is_default_tone")] + tone: SurfaceTone, + }, + /// Label/value list. + #[serde(rename = "keyValue")] + KeyValue { + /// Entries, rendered in order. + items: Vec, + }, + /// Grid of stat tiles. + #[serde(rename = "statGrid")] + StatGrid { + /// Stats, rendered in order. + stats: Vec, + }, + /// Semantic table. + #[serde(rename = "table")] + Table { + /// Column headers. Every row must have exactly this many cells. + columns: Vec, + /// Row cells. + rows: Vec>, + }, + /// Progress bar. Clients clamp `value` to 0–100. + #[serde(rename = "progress")] + Progress { + /// Optional bar label. + #[serde(default, skip_serializing_if = "Option::is_none")] + label: Option, + /// Progress value; must be a finite number. + value: serde_json::Number, + }, +} + +/// A `SurfaceSpec v1` document — the content of a surface event. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceSpecV1 { + /// Spec version; must be [`SURFACE_SPEC_VERSION`]. + pub version: u32, + /// Plain-text summary shown by non-rendering clients and all failure + /// paths. Required, non-empty. + #[serde(rename = "fallbackText")] + pub fallback_text: String, + /// Optional small uppercase label above the card body. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Display nodes, rendered in order (1–32). + pub nodes: Vec, +} + +impl SurfaceSpecV1 { + /// Validate the spec against every structural limit in the v1 protocol. + pub fn validate(&self) -> Result<(), SurfaceError> { + if self.version != SURFACE_SPEC_VERSION { + return Err(SurfaceError::new(format!( + "version: must be {SURFACE_SPEC_VERSION} (got {})", + self.version + ))); + } + check_text_field( + &self.fallback_text, + SURFACE_MAX_FALLBACK_CHARS, + "fallbackText", + )?; + if let Some(title) = &self.title { + check_text_field(title, SURFACE_MAX_TITLE_CHARS, "title")?; + } + if self.nodes.is_empty() { + return Err(SurfaceError::new("nodes: at least one node is required")); + } + if self.nodes.len() > SURFACE_MAX_NODES { + return Err(SurfaceError::new(format!( + "nodes: {} nodes exceeds max {SURFACE_MAX_NODES}", + self.nodes.len() + ))); + } + for (i, node) in self.nodes.iter().enumerate() { + validate_node(node, i)?; + } + Ok(()) + } + + /// Serialize to canonical JSON (stable field order, no insignificant + /// whitespace) and enforce [`SURFACE_MAX_CONTENT_BYTES`]. + pub fn canonical_json(&self) -> Result { + let json = serde_json::to_string(self) + .map_err(|e| SurfaceError::new(format!("serialize: {e}")))?; + if json.len() > SURFACE_MAX_CONTENT_BYTES { + return Err(SurfaceError::new(format!( + "content: {} bytes exceeds max {SURFACE_MAX_CONTENT_BYTES}", + json.len() + ))); + } + Ok(json) + } +} + +fn check_text_field(value: &str, max_chars: usize, field: &str) -> Result<(), SurfaceError> { + if value.trim().is_empty() { + return Err(SurfaceError::new(format!("{field}: must not be empty"))); + } + let len = value.chars().count(); + if len > max_chars { + return Err(SurfaceError::new(format!( + "{field}: {len} chars exceeds max {max_chars}" + ))); + } + Ok(()) +} + +fn check_value(value: &SurfaceValue, field: &str) -> Result<(), SurfaceError> { + value.check_finite(field)?; + let len = value.display_len(); + if len > SURFACE_MAX_SCALAR_CHARS { + return Err(SurfaceError::new(format!( + "{field}: {len} chars exceeds max {SURFACE_MAX_SCALAR_CHARS}" + ))); + } + Ok(()) +} + +fn validate_node(node: &SurfaceNode, i: usize) -> Result<(), SurfaceError> { + match node { + SurfaceNode::Heading { text } => check_text_field( + text, + SURFACE_MAX_SCALAR_CHARS, + &format!("nodes[{i}].heading.text"), + ), + SurfaceNode::Text { text } => check_text_field( + text, + SURFACE_MAX_TEXT_CHARS, + &format!("nodes[{i}].text.text"), + ), + SurfaceNode::Badge { text, .. } => check_text_field( + text, + SURFACE_MAX_SCALAR_CHARS, + &format!("nodes[{i}].badge.text"), + ), + SurfaceNode::KeyValue { items } => { + if items.is_empty() { + return Err(SurfaceError::new(format!( + "nodes[{i}].keyValue: at least one item is required" + ))); + } + if items.len() > SURFACE_MAX_ITEMS { + return Err(SurfaceError::new(format!( + "nodes[{i}].keyValue: {} items exceeds max {SURFACE_MAX_ITEMS}", + items.len() + ))); + } + for (j, item) in items.iter().enumerate() { + check_text_field( + &item.label, + SURFACE_MAX_SCALAR_CHARS, + &format!("nodes[{i}].keyValue.items[{j}].label"), + )?; + check_value( + &item.value, + &format!("nodes[{i}].keyValue.items[{j}].value"), + )?; + } + Ok(()) + } + SurfaceNode::StatGrid { stats } => { + if stats.is_empty() { + return Err(SurfaceError::new(format!( + "nodes[{i}].statGrid: at least one stat is required" + ))); + } + if stats.len() > SURFACE_MAX_ITEMS { + return Err(SurfaceError::new(format!( + "nodes[{i}].statGrid: {} stats exceeds max {SURFACE_MAX_ITEMS}", + stats.len() + ))); + } + for (j, stat) in stats.iter().enumerate() { + check_text_field( + &stat.label, + SURFACE_MAX_SCALAR_CHARS, + &format!("nodes[{i}].statGrid.stats[{j}].label"), + )?; + check_value( + &stat.value, + &format!("nodes[{i}].statGrid.stats[{j}].value"), + )?; + if let Some(delta) = &stat.delta { + check_value(delta, &format!("nodes[{i}].statGrid.stats[{j}].delta"))?; + } + } + Ok(()) + } + SurfaceNode::Table { columns, rows } => { + if columns.is_empty() { + return Err(SurfaceError::new(format!( + "nodes[{i}].table: at least one column is required" + ))); + } + if columns.len() > SURFACE_MAX_TABLE_COLUMNS { + return Err(SurfaceError::new(format!( + "nodes[{i}].table: {} columns exceeds max {SURFACE_MAX_TABLE_COLUMNS}", + columns.len() + ))); + } + if rows.len() > SURFACE_MAX_TABLE_ROWS { + return Err(SurfaceError::new(format!( + "nodes[{i}].table: {} rows exceeds max {SURFACE_MAX_TABLE_ROWS}", + rows.len() + ))); + } + for (j, col) in columns.iter().enumerate() { + check_text_field( + col, + SURFACE_MAX_SCALAR_CHARS, + &format!("nodes[{i}].table.columns[{j}]"), + )?; + } + for (r, row) in rows.iter().enumerate() { + if row.len() != columns.len() { + return Err(SurfaceError::new(format!( + "nodes[{i}].table.rows[{r}]: {} cells does not match {} columns", + row.len(), + columns.len() + ))); + } + for (c, cell) in row.iter().enumerate() { + check_value(cell, &format!("nodes[{i}].table.rows[{r}][{c}]"))?; + } + } + Ok(()) + } + SurfaceNode::Progress { label, value } => { + if let Some(label) = label { + check_text_field( + label, + SURFACE_MAX_SCALAR_CHARS, + &format!("nodes[{i}].progress.label"), + )?; + } + match value.as_f64() { + Some(v) if v.is_finite() => Ok(()), + _ => Err(SurfaceError::new(format!( + "nodes[{i}].progress.value: number must be finite" + ))), + } + } + } +} + +/// Parse and strictly validate surface event content. +/// +/// This is the relay's ingest gate: size cap first, then strict schema parse +/// (unknown fields, unknown node types, unknown tones, and non-scalar cells +/// all reject), then every structural limit. +pub fn parse_and_validate(content: &str) -> Result { + if content.len() > SURFACE_MAX_CONTENT_BYTES { + return Err(SurfaceError::new(format!( + "content: {} bytes exceeds max {SURFACE_MAX_CONTENT_BYTES}", + content.len() + ))); + } + let spec: SurfaceSpecV1 = serde_json::from_str(content) + .map_err(|e| SurfaceError::new(format!("invalid surface spec JSON: {e}")))?; + spec.validate()?; + Ok(spec) +} + +/// Normalize known field aliases observed from real models, in place. +/// +/// Producer-side only (SDK/CLI) — run *before* parsing/signing so stored +/// specs are canonical. The relay stays strict and rejects aliases. +/// +/// Current aliases: `table.fields` and `table.headers` → `table.columns`. +pub fn normalize_spec_aliases(value: &mut serde_json::Value) { + let Some(nodes) = value.get_mut("nodes").and_then(|n| n.as_array_mut()) else { + return; + }; + for node in nodes { + let Some(obj) = node.as_object_mut() else { + continue; + }; + let is_table = obj.get("type").and_then(|t| t.as_str()) == Some("table"); + if !is_table || obj.contains_key("columns") { + continue; + } + for alias in ["fields", "headers"] { + if let Some(cols) = obj.remove(alias) { + obj.insert("columns".to_string(), cols); + break; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn demo_spec_json() -> String { + r#"{ + "version": 1, + "fallbackText": "Deploy v2.4.1: 2/2 pods running, rollout 100%", + "title": "Deployment — api-gateway", + "nodes": [ + {"type": "badge", "text": "HEALTHY", "tone": "success"}, + {"type": "heading", "text": "Pods"}, + {"type": "text", "text": "All pods running."}, + {"type": "keyValue", "items": [{"label": "Version", "value": "v2.4.1", "tone": "info"}]}, + {"type": "statGrid", "stats": [{"label": "Pods", "value": 2, "delta": "+1", "tone": "success"}, {"label": "Errors", "value": 0}]}, + {"type": "table", "columns": ["Pod", "Status"], "rows": [["web-7d9f", "Running"], ["web-a1c2", 3]]}, + {"type": "progress", "label": "Rollout", "value": 100} + ] + }"# + .to_string() + } + + #[test] + fn parses_and_validates_all_node_types() { + let spec = parse_and_validate(&demo_spec_json()).expect("valid spec"); + assert_eq!(spec.version, 1); + assert_eq!(spec.nodes.len(), 7); + assert_eq!(spec.title.as_deref(), Some("Deployment — api-gateway")); + } + + #[test] + fn canonical_json_round_trips_and_is_stable() { + let spec = parse_and_validate(&demo_spec_json()).expect("valid spec"); + let canonical = spec.canonical_json().expect("canonical"); + let reparsed = parse_and_validate(&canonical).expect("canonical parses"); + assert_eq!(spec, reparsed); + assert_eq!(canonical, reparsed.canonical_json().expect("stable")); + // Integer representation is preserved (2 stays 2, not 2.0). + assert!(canonical.contains(r#""value":2,"#)); + } + + #[test] + fn rejects_wrong_version() { + let content = r#"{"version":2,"fallbackText":"x","nodes":[{"type":"text","text":"y"}]}"#; + let err = parse_and_validate(content).expect_err("version 2 rejected"); + assert!(err.to_string().contains("version"), "{err}"); + } + + #[test] + fn rejects_unknown_node_type_and_unknown_fields() { + let unknown_node = + r#"{"version":1,"fallbackText":"x","nodes":[{"type":"iframe","src":"https://x"}]}"#; + assert!(parse_and_validate(unknown_node).is_err()); + + let unknown_field = r#"{"version":1,"fallbackText":"x","onClick":"alert(1)","nodes":[{"type":"text","text":"y"}]}"#; + assert!(parse_and_validate(unknown_field).is_err()); + } + + #[test] + fn rejects_unknown_tone() { + let content = r#"{"version":1,"fallbackText":"x","nodes":[{"type":"badge","text":"B","tone":"sparkly"}]}"#; + assert!(parse_and_validate(content).is_err()); + } + + #[test] + fn rejects_non_scalar_cells() { + for cell in ["true", "null", "{}", "[1]"] { + let content = format!( + r#"{{"version":1,"fallbackText":"x","nodes":[{{"type":"table","columns":["A"],"rows":[[{cell}]]}}]}}"# + ); + assert!( + parse_and_validate(&content).is_err(), + "cell {cell} must be rejected" + ); + } + } + + #[test] + fn rejects_ragged_table_rows() { + let content = r#"{"version":1,"fallbackText":"x","nodes":[{"type":"table","columns":["A","B"],"rows":[["only-one"]]}]}"#; + let err = parse_and_validate(content).expect_err("ragged row rejected"); + assert!(err.to_string().contains("does not match"), "{err}"); + } + + #[test] + fn enforces_node_count_boundaries() { + let node = r#"{"type":"text","text":"y"}"#; + let build = |n: usize| { + format!( + r#"{{"version":1,"fallbackText":"x","nodes":[{}]}}"#, + vec![node; n].join(",") + ) + }; + assert!(parse_and_validate(&build(SURFACE_MAX_NODES)).is_ok()); + assert!(parse_and_validate(&build(SURFACE_MAX_NODES + 1)).is_err()); + assert!(parse_and_validate(r#"{"version":1,"fallbackText":"x","nodes":[]}"#).is_err()); + } + + #[test] + fn enforces_table_shape_boundaries() { + let cols_ok: Vec = (0..SURFACE_MAX_TABLE_COLUMNS) + .map(|i| format!("\"c{i}\"")) + .collect(); + let content = format!( + r#"{{"version":1,"fallbackText":"x","nodes":[{{"type":"table","columns":[{}],"rows":[]}}]}}"#, + cols_ok.join(",") + ); + assert!(parse_and_validate(&content).is_ok()); + + let cols_bad: Vec = (0..=SURFACE_MAX_TABLE_COLUMNS) + .map(|i| format!("\"c{i}\"")) + .collect(); + let content = format!( + r#"{{"version":1,"fallbackText":"x","nodes":[{{"type":"table","columns":[{}],"rows":[]}}]}}"#, + cols_bad.join(",") + ); + let err = parse_and_validate(&content).expect_err("13 columns rejected"); + assert!(err.to_string().contains("exceeds max 12"), "{err}"); + + let row = r#"["v"]"#; + let rows_bad = vec![row; SURFACE_MAX_TABLE_ROWS + 1].join(","); + let content = format!( + r#"{{"version":1,"fallbackText":"x","nodes":[{{"type":"table","columns":["A"],"rows":[{rows_bad}]}}]}}"# + ); + assert!(parse_and_validate(&content).is_err()); + } + + #[test] + fn enforces_scalar_and_text_lengths() { + let long = "x".repeat(SURFACE_MAX_SCALAR_CHARS + 1); + let content = format!( + r#"{{"version":1,"fallbackText":"x","nodes":[{{"type":"badge","text":"{long}"}}]}}"# + ); + assert!(parse_and_validate(&content).is_err()); + + let long_fallback = "x".repeat(SURFACE_MAX_FALLBACK_CHARS + 1); + let content = format!( + r#"{{"version":1,"fallbackText":"{long_fallback}","nodes":[{{"type":"text","text":"y"}}]}}"# + ); + assert!(parse_and_validate(&content).is_err()); + + let text_ok = "y".repeat(SURFACE_MAX_TEXT_CHARS); + let content = format!( + r#"{{"version":1,"fallbackText":"x","nodes":[{{"type":"text","text":"{text_ok}"}}]}}"# + ); + assert!(parse_and_validate(&content).is_ok()); + } + + #[test] + fn enforces_content_byte_cap() { + // A single text node may hold at most 4096 chars, so the byte cap + // needs many nodes — build 32 near-max text nodes (~128 KiB total). + let body = "y".repeat(SURFACE_MAX_TEXT_CHARS); + let node = format!(r#"{{"type":"text","text":"{body}"}}"#); + let content = format!( + r#"{{"version":1,"fallbackText":"x","nodes":[{}]}}"#, + vec![node.as_str(); SURFACE_MAX_NODES].join(",") + ); + assert!(content.len() > SURFACE_MAX_CONTENT_BYTES); + let err = parse_and_validate(&content).expect_err("oversize rejected"); + assert!(err.to_string().contains("exceeds max"), "{err}"); + } + + #[test] + fn normalizes_table_column_aliases() { + for alias in ["fields", "headers"] { + let mut value: serde_json::Value = serde_json::from_str(&format!( + r#"{{"version":1,"fallbackText":"x","nodes":[{{"type":"table","{alias}":["A"],"rows":[["v"]]}}]}}"# + )) + .expect("json"); + normalize_spec_aliases(&mut value); + let content = value.to_string(); + let spec = parse_and_validate(&content).expect("alias normalized"); + assert!(matches!(spec.nodes[0], SurfaceNode::Table { .. })); + } + // Explicit columns win over an alias; the alias would then reject + // downstream as an unknown field — normalization must not clobber. + let mut value: serde_json::Value = serde_json::from_str( + r#"{"version":1,"fallbackText":"x","nodes":[{"type":"table","columns":["A"],"rows":[["v"]]}]}"#, + ) + .expect("json"); + let before = value.clone(); + normalize_spec_aliases(&mut value); + assert_eq!(before, value); + } + + #[test] + fn tone_default_is_omitted_from_canonical_form() { + let content = r#"{"version":1,"fallbackText":"x","nodes":[{"type":"badge","text":"B","tone":"default"}]}"#; + let spec = parse_and_validate(content).expect("valid"); + let canonical = spec.canonical_json().expect("canonical"); + assert!(!canonical.contains("tone"), "{canonical}"); + } + + #[test] + fn rejects_empty_fallback_and_blank_text() { + let content = r#"{"version":1,"fallbackText":" ","nodes":[{"type":"text","text":"y"}]}"#; + assert!(parse_and_validate(content).is_err()); + let content = r#"{"version":1,"fallbackText":"x","nodes":[{"type":"heading","text":""}]}"#; + assert!(parse_and_validate(content).is_err()); + } +} diff --git a/crates/buzz-db/src/feed.rs b/crates/buzz-db/src/feed.rs index 40e58d0d06..d2b8804bbb 100644 --- a/crates/buzz-db/src/feed.rs +++ b/crates/buzz-db/src/feed.rs @@ -37,10 +37,51 @@ use buzz_core::kind::{ KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_GIT_ISSUE, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_JOB_PROGRESS, KIND_JOB_REQUEST, KIND_JOB_RESULT, KIND_STREAM_MESSAGE, - KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_WORKFLOW_APPROVAL_REQUESTED, + KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_SURFACE, KIND_TEXT_NOTE, + KIND_WORKFLOW_APPROVAL_REQUESTED, }; use buzz_core::{CommunityId, StoredEvent}; +/// Kinds the home-feed *mentions* query matches — conversational content that +/// can carry a `p` mention. Single source of truth for the SQL IN-list below +/// and the drift tests; keep in sync with desktop's HOME_MENTION_EVENT_KINDS. +pub const FEED_MENTION_KINDS: &[u32] = &[ + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, + KIND_SURFACE, + KIND_TEXT_NOTE, + KIND_FORUM_POST, + KIND_FORUM_COMMENT, + KIND_GIT_PULL_REQUEST, + KIND_GIT_PR_UPDATE, + KIND_GIT_ISSUE, + KIND_GIT_STATUS_OPEN, + KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_CLOSED, + KIND_GIT_STATUS_DRAFT, +]; + +/// Kinds the home-feed *activity* query matches. +pub const FEED_ACTIVITY_KINDS: &[u32] = &[ + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, + KIND_SURFACE, + KIND_FORUM_POST, + KIND_JOB_REQUEST, + KIND_JOB_PROGRESS, + KIND_JOB_RESULT, +]; + +/// Render a kind slice as a SQL `IN (...)` body. Values are compile-time +/// constants, never user input. +fn sql_kind_list(kinds: &[u32]) -> String { + kinds + .iter() + .map(|k| k.to_string()) + .collect::>() + .join(", ") +} + use crate::error::Result; use crate::event::row_to_stored_event; @@ -103,10 +144,8 @@ fn build_mentions_query( qb.push(" AND m.pubkey_hex = ").push_bind(pubkey_hex); qb.push(" AND e.deleted_at IS NULL"); qb.push(format!( - " AND e.kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, \ - {KIND_TEXT_NOTE}, {KIND_FORUM_POST}, {KIND_FORUM_COMMENT}, {KIND_GIT_PULL_REQUEST}, \ - {KIND_GIT_PR_UPDATE}, {KIND_GIT_ISSUE}, {KIND_GIT_STATUS_OPEN}, \ - {KIND_GIT_STATUS_MERGED}, {KIND_GIT_STATUS_CLOSED}, {KIND_GIT_STATUS_DRAFT})" + " AND e.kind IN ({})", + sql_kind_list(FEED_MENTION_KINDS) )); push_visible_channel_filter(&mut qb, "e.channel_id", accessible_channel_ids); if let Some(s) = since { @@ -262,8 +301,8 @@ fn build_activity_query( qb.push_bind(*community.as_uuid()); qb.push(" AND deleted_at IS NULL"); qb.push(format!( - " AND kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, {KIND_FORUM_POST}, \ - {KIND_JOB_REQUEST}, {KIND_JOB_PROGRESS}, {KIND_JOB_RESULT})" + " AND kind IN ({})", + sql_kind_list(FEED_ACTIVITY_KINDS) )); push_visible_channel_filter(&mut qb, "channel_id", accessible_channel_ids); if let Some(s) = since { @@ -618,16 +657,13 @@ mod tests { #[test] fn mentions_query_includes_stream_message_kind() { - use buzz_core::kind::{ - KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, - }; - let mention_kinds: &[u32] = &[ - KIND_STREAM_MESSAGE, - KIND_STREAM_MESSAGE_V2, - KIND_FORUM_POST, - KIND_FORUM_COMMENT, - ]; + use buzz_core::kind::{KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_STREAM_MESSAGE}; + let mention_kinds: &[u32] = FEED_MENTION_KINDS; + assert!( + mention_kinds.contains(&KIND_SURFACE), + "surface kind must be in mentions" + ); assert!( mention_kinds.contains(&KIND_STREAM_MESSAGE), "stream message kind must be in mentions" @@ -663,18 +699,8 @@ mod tests { #[test] fn activity_query_includes_agent_job_kinds() { - use buzz_core::kind::{ - KIND_FORUM_POST, KIND_JOB_PROGRESS, KIND_JOB_REQUEST, KIND_JOB_RESULT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, - }; - let activity_kinds: &[u32] = &[ - KIND_STREAM_MESSAGE, - KIND_STREAM_MESSAGE_V2, - KIND_FORUM_POST, - KIND_JOB_REQUEST, - KIND_JOB_PROGRESS, - KIND_JOB_RESULT, - ]; + use buzz_core::kind::{KIND_FORUM_POST, KIND_JOB_REQUEST, KIND_STREAM_MESSAGE}; + let activity_kinds: &[u32] = FEED_ACTIVITY_KINDS; assert!( activity_kinds.contains(&KIND_JOB_REQUEST), @@ -700,18 +726,7 @@ mod tests { #[test] fn activity_query_excludes_workflow_execution_kinds() { - use buzz_core::kind::{ - KIND_FORUM_POST, KIND_JOB_PROGRESS, KIND_JOB_REQUEST, KIND_JOB_RESULT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, - }; - let activity_kinds: &[u32] = &[ - KIND_STREAM_MESSAGE, - KIND_STREAM_MESSAGE_V2, - KIND_FORUM_POST, - KIND_JOB_REQUEST, - KIND_JOB_PROGRESS, - KIND_JOB_RESULT, - ]; + let activity_kinds: &[u32] = FEED_ACTIVITY_KINDS; use buzz_core::kind::{KIND_WORKFLOW_APPROVAL_DENIED, KIND_WORKFLOW_TRIGGERED}; for kind in KIND_WORKFLOW_TRIGGERED..=KIND_WORKFLOW_APPROVAL_DENIED { @@ -724,20 +739,9 @@ mod tests { #[test] fn needs_action_kinds_do_not_overlap_with_activity_kinds() { - use buzz_core::kind::{ - KIND_FORUM_POST, KIND_JOB_PROGRESS, KIND_JOB_REQUEST, KIND_JOB_RESULT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, - KIND_WORKFLOW_APPROVAL_REQUESTED, - }; + use buzz_core::kind::{KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED}; let needs_action_kinds: &[u32] = &[KIND_WORKFLOW_APPROVAL_REQUESTED, KIND_STREAM_REMINDER]; - let activity_kinds: &[u32] = &[ - KIND_STREAM_MESSAGE, - KIND_STREAM_MESSAGE_V2, - KIND_FORUM_POST, - KIND_JOB_REQUEST, - KIND_JOB_PROGRESS, - KIND_JOB_RESULT, - ]; + let activity_kinds: &[u32] = FEED_ACTIVITY_KINDS; for kind in needs_action_kinds { assert!( diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index a9cdffcdec..f9c47c0d03 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1163,6 +1163,7 @@ mod tests { use buzz_core::kind::{ KIND_AGENT_OBSERVER_FRAME, KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, + KIND_SURFACE, }; use buzz_core::observer::{ encrypt_observer_payload, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -1214,6 +1215,7 @@ mod tests { for kind in [ KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, + KIND_SURFACE, KIND_CANVAS, KIND_FORUM_POST, KIND_FORUM_VOTE, diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index fcd0d70728..5877f6e9bb 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -31,9 +31,10 @@ use buzz_core::kind::{ KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, - KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, - KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, - RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, + KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_SURFACE, KIND_TEAM, KIND_TEAM_CATALOG, + KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, + RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -253,6 +254,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), @@ -485,6 +487,7 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { | KIND_STREAM_MESSAGE_SCHEDULED | KIND_STREAM_REMINDER | KIND_STREAM_MESSAGE_DIFF + | KIND_SURFACE | KIND_CANVAS | KIND_FORUM_POST | KIND_FORUM_VOTE @@ -782,11 +785,15 @@ pub(crate) fn effective_message_author(event: &Event, relay_pubkey: &nostr::Publ /// 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. +/// Validate a kind:40003 edit: target exists, same channel, author-gated. +/// +/// Returns the target event's kind so the caller can apply kind-specific +/// content revalidation (e.g. a surface edit must itself be a valid spec). async fn validate_edit_ownership( community_id: CommunityId, event: &Event, state: &AppState, -) -> Result<(), String> { +) -> Result { let target_hex = event .tags .iter() @@ -859,7 +866,7 @@ async fn validate_edit_ownership( return Err("must be event author to edit".to_string()); } } - Ok(()) + Ok(event_kind_u32(&target_event.event)) } /// Validate kind:45002 vote targets a forum post (45001) or comment (45003). @@ -2343,9 +2350,15 @@ async fn ingest_event_inner( } if kind_u32 == KIND_STREAM_MESSAGE_EDIT { - validate_edit_ownership(tenant.community(), &event, state) + let target_kind = validate_edit_ownership(tenant.community(), &event, state) .await .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + // A surface edit is a full-spec replacement — the new content must + // itself be a valid SurfaceSpec, same strictness as first publish. + if target_kind == KIND_SURFACE { + buzz_core::surface::parse_and_validate(&event.content) + .map_err(|e| IngestError::Rejected(format!("invalid: surface edit: {e}")))?; + } } if kind_u32 == KIND_FORUM_VOTE { @@ -2358,6 +2371,11 @@ async fn ingest_event_inner( validate_diff_event(&event).map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + if kind_u32 == KIND_SURFACE { + buzz_core::surface::parse_and_validate(&event.content) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + if kind_u32 == KIND_AGENT_ENGRAM { validate_engram_envelope(&event) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; @@ -3038,6 +3056,7 @@ mod tests { for kind in [ KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, + KIND_SURFACE, KIND_CANVAS, KIND_FORUM_POST, KIND_FORUM_VOTE, diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 8cc9c8650a..e1161d61d5 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -11,8 +11,8 @@ use buzz_core::{ KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, - KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_USER_STATUS, - KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_SURFACE, + KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -374,6 +374,56 @@ pub fn build_diff_message( Ok(EventBuilder::new(Kind::Custom(40008), content).tags(tags)) } +/// Build a surface card message (`KIND_SURFACE`). +/// +/// Canonicalizes and validates the spec (structural limits + the 32 KiB +/// content cap from `buzz_core::surface`) before building — a bad spec fails +/// here with a field-specific error, never at the relay. +/// +/// - `channel_id`: target channel UUID +/// - `spec`: the typed `SurfaceSpec v1` payload +/// - `thread_ref`: optional NIP-10 reply context (surface as thread root or reply) +/// - `mentions`: pubkey hex strings to p-tag (deduped, max 50) +pub fn build_surface( + channel_id: Uuid, + spec: &buzz_core::surface::SurfaceSpecV1, + thread_ref: Option<&ThreadRef>, + mentions: &[&str], +) -> Result { + spec.validate() + .map_err(|e| SdkError::InvalidInput(format!("surface spec: {e}")))?; + let content = spec + .canonical_json() + .map_err(|e| SdkError::InvalidInput(format!("surface spec: {e}")))?; + let mut tags = vec![tag(&["h", &channel_id.to_string()])?]; + if let Some(tr) = thread_ref { + thread_tags(tr, &mut tags)?; + } + mention_tags(mentions, &mut tags)?; + Ok(EventBuilder::new(Kind::Custom(KIND_SURFACE as u16), &content).tags(tags)) +} + +/// Build a surface edit — full-spec replacement via the standard edit kind. +/// +/// Same canonicalize→validate path as [`build_surface`]; the relay +/// re-validates the replacement spec against the edit target. +pub fn build_surface_edit( + channel_id: Uuid, + target_event_id: nostr::EventId, + spec: &buzz_core::surface::SurfaceSpecV1, +) -> Result { + spec.validate() + .map_err(|e| SdkError::InvalidInput(format!("surface spec: {e}")))?; + let content = spec + .canonical_json() + .map_err(|e| SdkError::InvalidInput(format!("surface spec: {e}")))?; + let tags = vec![ + tag(&["h", &channel_id.to_string()])?, + tag(&["e", &target_event_id.to_hex()])?, + ]; + Ok(EventBuilder::new(Kind::Custom(40003), &content).tags(tags)) +} + /// Build an edit event targeting an existing message (kind 40003). pub fn build_edit( channel_id: Uuid, @@ -1886,6 +1936,69 @@ mod tests { }) } + #[test] + fn surface_happy_path_builds_canonical_content() { + use buzz_core::surface::parse_and_validate; + let cid = uuid(); + let spec = parse_and_validate( + r#"{"version":1,"fallbackText":"Deploy healthy","title":"Deploy","nodes":[{"type":"badge","text":"HEALTHY","tone":"success"},{"type":"progress","label":"Rollout","value":100}]}"#, + ) + .expect("valid spec"); + let ev = sign(build_surface(cid, &spec, None, &[]).unwrap()); + assert_eq!(ev.kind.as_u16(), 40110); + assert!(has_tag(&ev, "h", &cid.to_string())); + assert_eq!(ev.content, spec.canonical_json().expect("canonical")); + // Content parses back to an identical spec (canonical round-trip). + assert_eq!(parse_and_validate(&ev.content).expect("reparse"), spec); + } + + #[test] + fn surface_invalid_spec_fails_with_field_error() { + use buzz_core::surface::SurfaceSpecV1; + let spec = SurfaceSpecV1 { + version: 1, + fallback_text: "x".into(), + title: None, + nodes: vec![], + }; + let err = build_surface(uuid(), &spec, None, &[]).unwrap_err(); + assert!(err.to_string().contains("nodes"), "{err}"); + } + + #[test] + fn surface_thread_and_mentions_tagged() { + use buzz_core::surface::parse_and_validate; + let cid = uuid(); + let root = event_id(); + let spec = parse_and_validate( + r#"{"version":1,"fallbackText":"x","nodes":[{"type":"text","text":"y"}]}"#, + ) + .expect("valid spec"); + let tr = ThreadRef { + root_event_id: root, + parent_event_id: root, + }; + let mention = keys().public_key().to_hex(); + let ev = sign(build_surface(cid, &spec, Some(&tr), &[&mention]).unwrap()); + assert!(has_tag(&ev, "e", &root.to_hex())); + assert!(has_tag(&ev, "p", &mention)); + } + + #[test] + fn surface_edit_targets_event_with_replacement_spec() { + use buzz_core::surface::parse_and_validate; + let cid = uuid(); + let target = event_id(); + let spec = parse_and_validate( + r#"{"version":1,"fallbackText":"x","nodes":[{"type":"text","text":"y"}]}"#, + ) + .expect("valid spec"); + let ev = sign(build_surface_edit(cid, target, &spec).unwrap()); + assert_eq!(ev.kind.as_u16(), 40003); + assert!(has_tag(&ev, "e", &target.to_hex())); + assert_eq!(ev.content, spec.canonical_json().expect("canonical")); + } + #[test] fn message_happy_path() { let cid = uuid(); diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 6f59299ed2..41f5e78b6f 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -2913,3 +2913,483 @@ async fn test_nip29_relay_rejects_last_owner_self_demotion() { "the last owner must keep their role" ); } + +// ───────────────────────────────────────────────────────────────────────────── +// Surface Cards (KIND_SURFACE) — ingest validation, membership, edit gating. +// ───────────────────────────────────────────────────────────────────────────── + +const KIND_SURFACE_U16: u16 = 40110; +const KIND_EDIT_U16: u16 = 40003; + +fn surface_demo_spec(fallback: &str, badge_tone: &str, progress: u32) -> String { + format!( + r#"{{"version":1,"fallbackText":"{fallback}","title":"Deployment — api-gateway","nodes":[{{"type":"badge","text":"STATE","tone":"{badge_tone}"}},{{"type":"statGrid","stats":[{{"label":"Pods","value":2,"tone":"success"}},{{"label":"Errors","value":0}}]}},{{"type":"table","columns":["Pod","Status"],"rows":[["web-7d9f","Running"],["web-a1c2","Running"]]}},{{"type":"progress","label":"Rollout","value":{progress}}}]}}"# + ) +} + +fn build_surface_event(channel_id: &str, keys: &Keys, content: &str) -> nostr::Event { + EventBuilder::new(Kind::Custom(KIND_SURFACE_U16), content) + .tags(vec![Tag::parse(["h", channel_id]).unwrap()]) + .sign_with_keys(keys) + .unwrap() +} + +fn build_surface_edit_event( + channel_id: &str, + target_id: &str, + keys: &Keys, + content: &str, +) -> nostr::Event { + EventBuilder::new(Kind::Custom(KIND_EDIT_U16), content) + .tags(vec![ + Tag::parse(["h", channel_id]).unwrap(), + Tag::parse(["e", target_id]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap() +} + +/// A channel member's valid surface is accepted; a non-member's is rejected. +#[tokio::test] +#[ignore] +async fn test_surface_member_accepted_nonmember_rejected() { + let url = relay_url(); + let owner_keys = Keys::generate(); + let outsider_keys = Keys::generate(); + + let mut owner_client = BuzzTestClient::connect(&url, &owner_keys) + .await + .expect("connect as owner"); + let channel_id = create_private_channel_ws(&mut owner_client, &owner_keys).await; + + let ok = owner_client + .send_event(build_surface_event( + &channel_id, + &owner_keys, + &surface_demo_spec("Deploy healthy", "success", 100), + )) + .await + .expect("send surface"); + assert!(ok.accepted, "member surface rejected: {}", ok.message); + + let mut outsider_client = BuzzTestClient::connect(&url, &outsider_keys) + .await + .expect("connect as outsider"); + let ok = outsider_client + .send_event(build_surface_event( + &channel_id, + &outsider_keys, + &surface_demo_spec("Deploy healthy", "success", 100), + )) + .await + .expect("send outsider surface"); + assert!( + !ok.accepted, + "non-member surface must be rejected, got: {}", + ok.message + ); + + owner_client.disconnect().await.expect("disconnect owner"); + outsider_client + .disconnect() + .await + .expect("disconnect outsider"); +} + +/// Invalid schema, unknown version, unknown tone, and oversize payloads are +/// all rejected at ingest with a field-specific error. +#[tokio::test] +#[ignore] +async fn test_surface_invalid_payloads_rejected() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let channel_id = create_private_channel_ws(&mut client, &keys).await; + + let cases: Vec<(&str, String)> = vec![ + ("broken JSON", "{not json".to_string()), + ( + "unknown version", + r#"{"version":2,"fallbackText":"x","nodes":[{"type":"text","text":"y"}]}"#.to_string(), + ), + ( + "unknown node type", + r#"{"version":1,"fallbackText":"x","nodes":[{"type":"iframe","src":"https://x"}]}"# + .to_string(), + ), + ( + "unknown tone", + r#"{"version":1,"fallbackText":"x","nodes":[{"type":"badge","text":"B","tone":"sparkly"}]}"# + .to_string(), + ), + ( + "boolean cell", + r#"{"version":1,"fallbackText":"x","nodes":[{"type":"table","columns":["A"],"rows":[[true]]}]}"# + .to_string(), + ), + ( + "13 columns", + format!( + r#"{{"version":1,"fallbackText":"x","nodes":[{{"type":"table","columns":[{}],"rows":[]}}]}}"#, + (0..13) + .map(|i| format!("\"c{i}\"")) + .collect::>() + .join(",") + ), + ), + ("oversize", { + let body = "y".repeat(4096); + let node = format!(r#"{{"type":"text","text":"{body}"}}"#); + format!( + r#"{{"version":1,"fallbackText":"x","nodes":[{}]}}"#, + vec![node.as_str(); 32].join(",") + ) + }), + ]; + + for (name, content) in cases { + let ok = client + .send_event(build_surface_event(&channel_id, &keys, &content)) + .await + .unwrap_or_else(|e| panic!("send {name}: {e}")); + assert!(!ok.accepted, "{name} must be rejected"); + assert!( + ok.message.starts_with("invalid:"), + "{name}: expected field-specific 'invalid:' error, got: {}", + ok.message + ); + } + + client.disconnect().await.expect("disconnect"); +} + +/// Live-update semantics: the author's edits replace the spec (latest wins); +/// an invalid replacement spec is rejected; a non-author's edit is rejected. +#[tokio::test] +#[ignore] +async fn test_surface_edit_replacement_author_gated() { + let url = relay_url(); + let alice = Keys::generate(); + let bob = Keys::generate(); + + let mut alice_client = BuzzTestClient::connect(&url, &alice) + .await + .expect("connect as alice"); + let channel_id = create_private_channel_ws(&mut alice_client, &alice).await; + let (accepted, msg) = add_member_ws( + &mut alice_client, + &channel_id, + &bob.public_key().to_hex(), + &alice, + ) + .await; + assert!(accepted, "add bob: {msg}"); + + // Alice publishes the surface. + let surface = build_surface_event( + &channel_id, + &alice, + &surface_demo_spec("Deploy healthy", "success", 100), + ); + let surface_id = surface.id.to_hex(); + let ok = alice_client.send_event(surface).await.expect("publish"); + assert!(ok.accepted, "publish surface: {}", ok.message); + + // Alice edits twice — healthy → incident → recovered. Both accepted. + for (fallback, tone, progress) in [ + ("Deploy DEGRADED: 1/2 pods", "danger", 35), + ("Deploy recovered", "success", 100), + ] { + let ok = alice_client + .send_event(build_surface_edit_event( + &channel_id, + &surface_id, + &alice, + &surface_demo_spec(fallback, tone, progress), + )) + .await + .expect("send edit"); + assert!(ok.accepted, "author edit rejected: {}", ok.message); + } + + // An edit whose content is not a valid spec is rejected. + let ok = alice_client + .send_event(build_surface_edit_event( + &channel_id, + &surface_id, + &alice, + r#"{"version":1,"fallbackText":"x","nodes":[]}"#, + )) + .await + .expect("send invalid edit"); + assert!(!ok.accepted, "invalid surface edit must be rejected"); + assert!( + ok.message.contains("surface edit"), + "expected surface-edit validation error, got: {}", + ok.message + ); + + // Bob (member, but not the author) cannot edit Alice's surface. + let mut bob_client = BuzzTestClient::connect(&url, &bob) + .await + .expect("connect as bob"); + let ok = bob_client + .send_event(build_surface_edit_event( + &channel_id, + &surface_id, + &bob, + &surface_demo_spec("Bob was here", "danger", 1), + )) + .await + .expect("send bob edit"); + assert!( + !ok.accepted, + "non-author edit must be rejected, got: {}", + ok.message + ); + assert!( + ok.message.contains("author"), + "expected author-gating error, got: {}", + ok.message + ); + + alice_client.disconnect().await.expect("disconnect alice"); + bob_client.disconnect().await.expect("disconnect bob"); +} + +/// An edit targeting a surface in a DIFFERENT channel than the edit's own `h` +/// tag is rejected — a cross-channel edit must not mutate a foreign surface. +#[tokio::test] +#[ignore] +async fn test_surface_edit_cross_channel_rejected() { + let url = relay_url(); + let alice = Keys::generate(); + + let mut client = BuzzTestClient::connect(&url, &alice) + .await + .expect("connect"); + let channel_a = create_private_channel_ws(&mut client, &alice).await; + let channel_b = create_private_channel_ws(&mut client, &alice).await; + + // Publish a surface in channel A. + let surface = build_surface_event( + &channel_a, + &alice, + &surface_demo_spec("in channel A", "success", 100), + ); + let surface_id = surface.id.to_hex(); + let ok = client.send_event(surface).await.expect("publish A"); + assert!(ok.accepted, "publish in A: {}", ok.message); + + // Edit event carries channel B's h tag but points its e tag at A's surface. + let ok = client + .send_event(build_surface_edit_event( + &channel_b, + &surface_id, + &alice, + &surface_demo_spec("hijacked into B", "danger", 1), + )) + .await + .expect("send cross-channel edit"); + assert!( + !ok.accepted, + "cross-channel surface edit must be rejected, got: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} + +/// An accepted surface edit is actually stored and fanned out: after editing, +/// a fresh subscription for the surface id returns the REPLACEMENT spec, not +/// the original — proving the edit took effect, not merely that it was OK'd. +#[tokio::test] +#[ignore] +async fn test_surface_edit_replacement_is_observable() { + let url = relay_url(); + let alice = Keys::generate(); + + let mut client = BuzzTestClient::connect(&url, &alice) + .await + .expect("connect"); + let channel_id = create_private_channel_ws(&mut client, &alice).await; + + let surface = build_surface_event( + &channel_id, + &alice, + &surface_demo_spec("original", "success", 100), + ); + let surface_id = surface.id.to_hex(); + client.send_event(surface).await.expect("publish"); + + // Edit to a spec whose fallbackText carries a unique marker. + let marker = format!("edited-{}", uuid::Uuid::new_v4()); + let ok = client + .send_event(build_surface_edit_event( + &channel_id, + &surface_id, + &alice, + &surface_demo_spec(&marker, "danger", 20), + )) + .await + .expect("send edit"); + assert!(ok.accepted, "edit rejected: {}", ok.message); + + // Read the edit event back by referencing the surface via #e. + let sid = sub_id("surface-edit-readback"); + let filter = Filter::new().kind(Kind::Custom(KIND_EDIT_U16)).custom_tags( + SingleLetterTag::lowercase(Alphabet::E), + [surface_id.as_str()], + ); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("EOSE"); + + assert!( + events.iter().any(|e| e.content.contains(&marker)), + "stored edit must contain the replacement spec marker {marker}; got {} events", + events.len() + ); + + client.disconnect().await.expect("disconnect"); +} + +/// Surfaces are excluded from NIP-50 full-text search: a unique token placed +/// in a surface's spec is NOT found by search, while the same token in a +/// kind-9 message IS — proving the search path works and the exclusion is +/// intentional (FTS allowlist, migration 0008). +#[tokio::test] +#[ignore] +async fn test_surface_excluded_from_fts() { + let url = relay_url(); + let alice = Keys::generate(); + + let mut client = BuzzTestClient::connect(&url, &alice) + .await + .expect("connect"); + let channel_id = create_private_channel_ws(&mut client, &alice).await; + + let token = format!("surfacefts{}", uuid::Uuid::new_v4().simple()); + + // Publish the token inside a surface spec AND inside a kind-9 message. + let surface = build_surface_event( + &channel_id, + &alice, + &surface_demo_spec(&token, "success", 100), + ); + client.send_event(surface).await.expect("publish surface"); + + let msg = EventBuilder::new(Kind::Custom(9), format!("plain {token} message")) + .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&alice) + .unwrap(); + client.send_event(msg).await.expect("publish message"); + + // NIP-50 search scoped to both content kinds. + let sid = sub_id("surface-fts"); + let filter = Filter::new() + .kinds([Kind::Custom(9), Kind::Custom(KIND_SURFACE_U16)]) + .search(&token) + .custom_tags( + SingleLetterTag::lowercase(Alphabet::H), + [channel_id.as_str()], + ); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("EOSE"); + + let kinds: Vec = events.iter().map(|e| e.kind.as_u16()).collect(); + assert!( + kinds.contains(&9), + "kind-9 message with the token must be searchable (proves FTS works); got {kinds:?}" + ); + assert!( + !kinds.contains(&KIND_SURFACE_U16), + "surface content must NOT be full-text searchable; got {kinds:?}" + ); + + client.disconnect().await.expect("disconnect"); +} + +/// A surface carrying a `p` tag actually reaches the mentioned person. +/// +/// The recipient is added to the (private) channel and does the read on their +/// OWN authenticated connection — the path Home feeds, notifications and agent +/// mention filters use. Querying on the author's connection would pass even if +/// the recipient could never see the event, so this test deliberately does not. +#[tokio::test] +#[ignore] +async fn test_surface_mention_is_deliverable() { + let url = relay_url(); + let author = Keys::generate(); + let mentioned = Keys::generate(); + let mentioned_hex = mentioned.public_key().to_hex(); + + let mut author_client = BuzzTestClient::connect(&url, &author) + .await + .expect("connect as author"); + let channel_id = create_private_channel_ws(&mut author_client, &author).await; + + // The recipient must be a channel member — a p tag alone does not grant + // read access to a private channel. + let (accepted, msg) = + add_member_ws(&mut author_client, &channel_id, &mentioned_hex, &author).await; + assert!(accepted, "add mentioned member: {msg}"); + + let marker = format!("mention-{}", uuid::Uuid::new_v4()); + let event = EventBuilder::new( + Kind::Custom(KIND_SURFACE_U16), + surface_demo_spec(&marker, "success", 100), + ) + .tags(vec![ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["p", &mentioned_hex]).unwrap(), + ]) + .sign_with_keys(&author) + .unwrap(); + let ok = author_client.send_event(event).await.expect("publish"); + assert!(ok.accepted, "mention surface rejected: {}", ok.message); + + // Read as the RECIPIENT, on their own connection. + let mut recipient_client = BuzzTestClient::connect(&url, &mentioned) + .await + .expect("connect as mentioned user"); + let sid = sub_id("surface-mention"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_SURFACE_U16)) + .custom_tags( + SingleLetterTag::lowercase(Alphabet::P), + [mentioned_hex.as_str()], + ); + recipient_client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = recipient_client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("EOSE"); + + assert!( + events.iter().any(|e| e.content.contains(&marker)), + "the mentioned user must receive the card through their own #p query; \ + got {} events", + events.len() + ); + + author_client.disconnect().await.expect("disconnect author"); + recipient_client + .disconnect() + .await + .expect("disconnect recipient"); +} diff --git a/desktop/src-tauri/src/commands/channel_window.rs b/desktop/src-tauri/src/commands/channel_window.rs index 9230cdd2ed..a08db3cda4 100644 --- a/desktop/src-tauri/src/commands/channel_window.rs +++ b/desktop/src-tauri/src/commands/channel_window.rs @@ -1,21 +1,8 @@ use tauri::State; +use crate::commands::query_kinds::TIMELINE_KINDS; use crate::{app_state::AppState, models::ChannelPageCursor, relay::query_relay}; -const TIMELINE_KINDS: [u32; 11] = [ - 9, - 40002, - 40008, - 40099, - 43001, - 43002, - 43003, - 43004, - 43005, - 43006, - buzz_core_pkg::kind::KIND_HUDDLE_STARTED, -]; - fn build_channel_window_filter( channel_id: &str, cap: u32, diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 59c80c4807..7ac0677651 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -242,7 +242,9 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result .iter() .map(|id| { serde_json::json!({ - "kinds": [9, 40002], + // Surface-only activity must advance channel recency too — + // a card is a message someone posted. + "kinds": buzz_core_pkg::kind::CONVERSATIONAL_KINDS, "#h": [id], "limit": 1 }) diff --git a/desktop/src-tauri/src/commands/edit_overlay.rs b/desktop/src-tauri/src/commands/edit_overlay.rs new file mode 100644 index 0000000000..ff9010f252 --- /dev/null +++ b/desktop/src-tauri/src/commands/edit_overlay.rs @@ -0,0 +1,213 @@ +//! Latest-edit overlay for read paths that project stored events directly. +//! +//! A `kind:40003` edit carries the full replacement content for the event its +//! `e` tag points at. Any reader that renders stored content without applying +//! these shows the original forever — which is merely stale for a normal +//! message, but wrong for a surface card, whose entire update model is +//! full-spec replacement. +//! +//! `created_at` is second-precision, so two edits can tie. Ties break on the +//! lexicographically SMALLEST event id — not an arbitrary choice: the relay +//! orders `created_at DESC, id ASC`, so the winner under this rule is always +//! the first row a query returns. That makes a `limit: 1` per-target lookup +//! provably sufficient, and it is the same rule the channel timeline uses +//! (`formatTimelineMessages`), so every reader converges on one state. +//! +//! Edits must be fetched **by target id**, never by scanning a channel window: +//! an edit tags the event it replaces (`e` = that event's id), so a reply's +//! edit does not carry the thread root's id, and a busy channel can push the +//! relevant edit outside any window. Hence the two-phase read — fetch the +//! content page, then [`fetch_latest_edits`] for exactly the ids it returned. + +use std::collections::HashMap; + +use buzz_core_pkg::kind::KIND_STREAM_MESSAGE_EDIT; +use nostr::Event; + +use crate::app_state::AppState; +use crate::models::FeedItemInfo; +use crate::relay::query_relay; + +/// Filters sent per query. One filter per target keeps a heavily-edited event +/// from crowding out other targets — a single OR-ed `#e` filter shares one +/// row budget (the relay caps a filter at 1000 rows), so one noisy card could +/// hide every other card's edit. +const EDIT_FILTERS_PER_QUERY: usize = 25; + +/// Rows per target. One is enough: the relay orders `created_at DESC, id ASC` +/// and the tie-break picks the smallest id, so the first row IS the winner. +const EDIT_ROWS_PER_TARGET: usize = 1; + +/// Fetch the latest edit content for exactly `target_ids`. +/// +/// Returns `target event id -> replacement content`. Failures are non-fatal: +/// a read that cannot reach the relay renders original content rather than +/// failing outright, which is the same degradation the callers already have. +pub(crate) async fn fetch_latest_edits( + state: &AppState, + target_ids: &[String], + channel_id: Option<&str>, +) -> HashMap { + let mut overlay: HashMap = HashMap::new(); + for chunk in target_ids.chunks(EDIT_FILTERS_PER_QUERY) { + let filters: Vec = chunk + .iter() + .map(|id| { + let mut filter = serde_json::json!({ + "kinds": [KIND_STREAM_MESSAGE_EDIT], + "#e": [id], + "limit": EDIT_ROWS_PER_TARGET, + }); + if let Some(channel_id) = channel_id { + filter["#h"] = serde_json::json!([channel_id]); + } + filter + }) + .collect(); + match query_relay(state, &filters).await { + Ok(events) => overlay.extend(latest_edit_by_target(&events)), + Err(error) => { + tracing::warn!( + "edit overlay fetch failed ({} targets): {error}", + chunk.len() + ); + } + } + } + overlay +} + +/// Overlay current content onto feed items, looked up by their exact ids. +/// +/// Feed rows render the event's content — a surface card's `fallbackText` — +/// so without this an updated card keeps its original summary in the Inbox +/// while the detail view shows the new one. +pub(crate) async fn apply_to_feed_items( + state: &AppState, + events: &[Event], + items: &mut [FeedItemInfo], +) { + let ids: Vec = events.iter().map(|ev| ev.id.to_hex()).collect(); + let overlay = fetch_latest_edits(state, &ids, None).await; + if overlay.is_empty() { + return; + } + for item in items.iter_mut() { + if let Some(content) = overlay.get(&item.id) { + item.content = content.clone(); + } + } +} + +/// Map of `target event id -> replacement content`, keeping the latest edit +/// per target under `(created_at, id)` ordering. +pub(crate) fn latest_edit_by_target(events: &[Event]) -> HashMap { + // (created_at, edit id, content) per target, so ties resolve deterministically. + let mut best: HashMap = HashMap::new(); + + for event in events { + if u32::from(event.kind.as_u16()) != KIND_STREAM_MESSAGE_EDIT { + continue; + } + let Some(target) = event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 2 && parts[0].as_str() == "e").then(|| parts[1].to_string()) + }) else { + continue; + }; + + let created_at = event.created_at.as_secs(); + let id = event.id.to_hex(); + let wins = best.get(&target).is_none_or(|(at, existing_id, _)| { + created_at > *at || (created_at == *at && id < *existing_id) + }); + if wins { + best.insert(target, (created_at, id, event.content.clone())); + } + } + + best.into_iter() + .map(|(target, (_, _, content))| (target, content)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn edit(keys: &Keys, target: &str, content: &str, created_at: u64) -> Event { + EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE_EDIT as u16), content) + .tags([Tag::parse(["e", target]).expect("tag")]) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(keys) + .expect("sign") + } + + #[test] + fn latest_edit_wins_by_timestamp() { + let keys = Keys::generate(); + let target = "a".repeat(64); + let events = vec![ + edit(&keys, &target, "first", 1_700_000_000), + edit(&keys, &target, "second", 1_700_000_010), + ]; + let overlay = latest_edit_by_target(&events); + assert_eq!(overlay.get(&target).map(String::as_str), Some("second")); + } + + #[test] + fn same_second_edits_break_the_tie_on_event_id() { + let keys = Keys::generate(); + let target = "b".repeat(64); + let a = edit(&keys, &target, "one", 1_700_000_000); + let b = edit(&keys, &target, "two", 1_700_000_000); + // Smallest id wins — see the module docs for why that is the rule. + let expected = if a.id.to_hex() < b.id.to_hex() { + "one" + } else { + "two" + }; + + // Order of arrival must not change the outcome. + for events in [vec![a.clone(), b.clone()], vec![b, a]] { + let overlay = latest_edit_by_target(&events); + assert_eq!(overlay.get(&target).map(String::as_str), Some(expected)); + } + } + + #[test] + fn overlay_is_addressed_per_target_not_per_thread() { + // A reply's edit tags the REPLY, never the thread root. A reader that + // looked edits up by root id (or by scanning a channel window) would + // leave an edited reply — a surface card posted as a reply — stale. + let keys = Keys::generate(); + let root = "1".repeat(64); + let reply = "2".repeat(64); + let events = vec![ + edit(&keys, &root, "root updated", 1_700_000_000), + edit(&keys, &reply, "reply updated", 1_700_000_000), + ]; + let overlay = latest_edit_by_target(&events); + assert_eq!(overlay.get(&root).map(String::as_str), Some("root updated")); + assert_eq!( + overlay.get(&reply).map(String::as_str), + Some("reply updated"), + "an edit addressed to a reply must resolve to that reply" + ); + } + + #[test] + fn non_edit_events_and_untargeted_edits_are_ignored() { + let keys = Keys::generate(); + let message = EventBuilder::new(Kind::Custom(9), "hello") + .tags([]) + .sign_with_keys(&keys) + .expect("sign"); + let untargeted = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE_EDIT as u16), "orphan") + .tags([]) + .sign_with_keys(&keys) + .expect("sign"); + assert!(latest_edit_by_target(&[message, untargeted]).is_empty()); + } +} diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index b7c37bec3d..7e04722a34 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -27,19 +27,9 @@ use crate::{ /// (`p_gated_filters_authorized`) without a `#p` tag — load-bearing for the /// thread-subtree read, whose relay routing keys off `#e`+`depth_limit` (not /// kind) but still passes through the p-gate before it runs. -const TIMELINE_KINDS: [u32; 11] = [ - 9, - 40002, - 40008, - 40099, - 43001, - 43002, - 43003, - 43004, - 43005, - 43006, - buzz_core_pkg::kind::KIND_HUDDLE_STARTED, -]; +use crate::commands::query_kinds::{ + forum_root_kinds, forum_thread_kinds, mention_kinds, thread_parent_kinds, TIMELINE_KINDS, +}; #[tauri::command] pub async fn get_feed( @@ -68,20 +58,7 @@ pub async fn get_feed( // Mentions: messages that reference me via #p. let mut mention_filter = serde_json::json!({ - "kinds": [ - 9, - 40002, - 1, - 45001, - 45003, - buzz_core_pkg::kind::KIND_GIT_PULL_REQUEST, - buzz_core_pkg::kind::KIND_GIT_PR_UPDATE, - buzz_core_pkg::kind::KIND_GIT_ISSUE, - buzz_core_pkg::kind::KIND_GIT_STATUS_OPEN, - buzz_core_pkg::kind::KIND_GIT_STATUS_MERGED, - buzz_core_pkg::kind::KIND_GIT_STATUS_CLOSED, - buzz_core_pkg::kind::KIND_GIT_STATUS_DRAFT, - ], + "kinds": mention_kinds(), "#p": [my_pubkey], "limit": cap, }); @@ -113,10 +90,12 @@ pub async fn get_feed( Vec::new() }; - let mentions: Vec = mention_events + let mut mentions: Vec = mention_events .iter() .map(|ev| feed_item_from_event(ev, "mentions")) .collect(); + crate::commands::edit_overlay::apply_to_feed_items(&state, &mention_events, &mut mentions) + .await; let needs_action: Vec = approval_events .iter() .map(|ev| feed_item_from_event(ev, "needs_action")) @@ -215,7 +194,7 @@ pub async fn get_forum_posts( ) -> Result { let cap = limit.unwrap_or(20).min(100); let mut filter = serde_json::Map::new(); - filter.insert("kinds".to_string(), serde_json::json!([45001])); + filter.insert("kinds".to_string(), serde_json::json!(forum_root_kinds())); filter.insert("#h".to_string(), serde_json::json!([channel_id.clone()])); filter.insert("limit".to_string(), serde_json::json!(cap)); if let Some(t) = before { @@ -249,9 +228,9 @@ pub async fn get_forum_thread( let events = query_relay( &state, &[ - serde_json::json!({ "ids": [event_id.clone()], "kinds": [9, 40002, 45001, 45003] }), + serde_json::json!({ "ids": [event_id.clone()], "kinds": forum_thread_kinds() }), serde_json::json!({ - "kinds": [9, 45003], + "kinds": forum_thread_kinds(), "#e": [event_id.clone()], "#h": [channel_id.clone()], }), @@ -259,13 +238,33 @@ pub async fn get_forum_thread( ) .await?; + // Second phase: edits are addressed to the event they replace, so a reply's + // edit carries the REPLY's id, not the root's. Look them up by the exact + // ids this page returned — a channel-window scan would miss reply edits + // entirely and could drop the relevant one on a busy channel. + let target_ids: Vec = events.iter().map(|ev| ev.id.to_hex()).collect(); + let latest_edit = + crate::commands::edit_overlay::fetch_latest_edits(&state, &target_ids, Some(&channel_id)) + .await; + let mut root: Option = None; let mut replies: Vec = Vec::new(); for ev in &events { + if u32::from(ev.kind.as_u16()) == buzz_core_pkg::kind::KIND_STREAM_MESSAGE_EDIT { + continue; + } if ev.id.to_hex() == event_id { - root = Some(forum_message_from_event(ev, &channel_id)); + let mut info = forum_message_from_event(ev, &channel_id); + if let Some(content) = latest_edit.get(&info.event_id) { + info.content = content.clone(); + } + root = Some(info); } else { - replies.push(forum_reply_from_event(ev, &channel_id, &event_id)); + let mut info = forum_reply_from_event(ev, &channel_id, &event_id); + if let Some(content) = latest_edit.get(&info.event_id) { + info.content = content.clone(); + } + replies.push(info); } } let total_replies = replies.len() as u32; @@ -463,7 +462,7 @@ pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result Vec { + kinds_with(&[ + KIND_TEXT_NOTE, + KIND_FORUM_POST, + KIND_FORUM_COMMENT, + KIND_GIT_PULL_REQUEST, + KIND_GIT_PR_UPDATE, + KIND_GIT_ISSUE, + KIND_GIT_STATUS_OPEN, + KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_CLOSED, + KIND_GIT_STATUS_DRAFT, + ]) +} + +/// Kinds that can be a thread parent — anything repliable. +pub(crate) fn thread_parent_kinds() -> Vec { + kinds_with(&[KIND_FORUM_POST, KIND_FORUM_COMMENT, KIND_HUDDLE_STARTED]) +} + +/// Kinds that can be a forum thread root. +/// +/// Forum posts plus surfaces — NIP-SC allows a surface as a thread root, and +/// without it a card posted to a forum channel is invisible in the index even +/// though the thread view renders it. +pub(crate) fn forum_root_kinds() -> Vec { + vec![KIND_FORUM_POST, KIND_SURFACE] +} + +/// Kinds a forum thread is built from (root lookup and reply fan-out). +pub(crate) fn forum_thread_kinds() -> Vec { + kinds_with(&[KIND_FORUM_POST, KIND_FORUM_COMMENT]) +} + +#[cfg(test)] +mod tests { + use super::TIMELINE_KINDS; + + #[test] + fn timeline_kinds_include_surfaces() { + assert!( + TIMELINE_KINDS.contains(&buzz_core_pkg::kind::KIND_SURFACE), + "surfaces must load from history, not only live subscriptions" + ); + } + + #[test] + fn every_query_set_carries_surfaces() { + // A surface is conversational content: mention feeds, thread-parent + // resolution and forum threads must all accept it, or cards silently + // vanish from those paths. + for (name, kinds) in [ + ("mention", super::mention_kinds()), + ("thread_parent", super::thread_parent_kinds()), + ("forum_thread", super::forum_thread_kinds()), + ("forum_root", super::forum_root_kinds()), + ] { + assert!( + kinds.contains(&buzz_core_pkg::kind::KIND_SURFACE), + "{name} query set must include surfaces" + ); + } + } + + #[test] + fn timeline_kinds_have_no_duplicates() { + let mut sorted = TIMELINE_KINDS; + sorted.sort_unstable(); + let mut deduped = sorted.to_vec(); + deduped.dedup(); + assert_eq!( + deduped.len(), + TIMELINE_KINDS.len(), + "duplicate kind in TIMELINE_KINDS" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 79a5ea301d..a7c0026c10 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -140,6 +140,25 @@ buzz messages send --channel \ Other kind values are rejected. Use `messages vote --event --direction up|down` to vote on forum posts. +## Surface Cards + +For live structured status (deploys, incidents, metrics, checklists), publish a surface card — a data-only JSON spec the client renders as a native card. Edit it in place instead of posting update after update. + +```bash +buzz messages send-surface --channel --spec - <<'JSON' +{"version":1,"fallbackText":"Deploy: 2/2 pods, rollout 100%","title":"Deployment","nodes":[ + {"type":"badge","text":"HEALTHY","tone":"success"}, + {"type":"statGrid","stats":[{"label":"Pods","value":2,"tone":"success"},{"label":"Errors","value":0}]}, + {"type":"progress","label":"Rollout","value":100}]} +JSON +``` + +- Nodes: `heading`, `text` (plain, no markdown), `badge`, `keyValue` (`items:[{label,value,tone?}]`), `statGrid` (`stats:[{label,value,delta?,tone?}]`), `table` (`columns` + `rows`, ≤12×100), `progress` (`value` 0–100). Tones: `default|success|warning|danger|info`. +- `fallbackText` is required — it is what non-rendering clients show. +- **To notify someone, pass `--mention `** (repeatable). Card content is JSON, so `@name` inside the spec is NOT parsed as a mention. +- **Save the returned `event_id`** — live-update the card with `buzz messages edit-surface --event --spec ` (full-spec replacement; only the author can edit). +- Limits: ≤32 nodes, ≤32 KiB JSON, text ≤4096 chars, labels/values ≤512 chars. Validation errors name the exact field — fix and resend. + ## Message Formatting Message content is rendered as GitHub-flavored Markdown on both desktop and mobile. Key formatting: diff --git a/desktop/src/app/useAppShellDesktopNotifications.ts b/desktop/src/app/useAppShellDesktopNotifications.ts index 2266862cac..50dec91a9c 100644 --- a/desktop/src/app/useAppShellDesktopNotifications.ts +++ b/desktop/src/app/useAppShellDesktopNotifications.ts @@ -1,3 +1,5 @@ +import { surfacePreviewText } from "@/features/surfaces/spec"; +import { KIND_SURFACE } from "@/shared/constants/kinds"; import * as React from "react"; import { @@ -23,6 +25,17 @@ import { } from "@/features/notifications/lib/sound"; import type { Channel, RelayEvent } from "@/shared/api/types"; +// Surface events carry spec JSON as content — never show raw JSON in an OS +// notification. Mirrors the home-feed/inbox preview path. +function notificationContent(event: { + kind?: number; + content: string; +}): string { + return event.kind === KIND_SURFACE + ? surfacePreviewText(event.content) + : event.content; +} + export function useAppShellDesktopNotifications({ channels, goChannel, @@ -58,7 +71,10 @@ export function useAppShellDesktopNotifications({ } const channelName = channel.name?.trim() || "Direct message"; - const body = truncateNotificationBody(event.content, "New message"); + const body = truncateNotificationBody( + notificationContent(event), + "New message", + ); const threadRootId = getThreadReference(event.tags).rootId ?? null; void sendDesktopNotification({ @@ -67,7 +83,7 @@ export function useAppShellDesktopNotifications({ target: { channelId: channel.id, channelName, - content: event.content, + content: notificationContent(event), createdAt: event.created_at, eventId: event.id, kind: event.kind, @@ -103,7 +119,10 @@ export function useAppShellDesktopNotifications({ // channelLabel is "#name" for the toast title; channelName is the raw // name stored in the navigation target for click-through routing. const channelLabel = channelName ? `#${channelName}` : null; - const body = truncateNotificationBody(event.content, "New reply"); + const body = truncateNotificationBody( + notificationContent(event), + "New reply", + ); const threadRootId = getThreadReference(event.tags).rootId ?? null; void sendDesktopNotification({ @@ -112,7 +131,7 @@ export function useAppShellDesktopNotifications({ target: { channelId, channelName, - content: event.content, + content: notificationContent(event), createdAt: event.created_at, eventId: event.id, kind: event.kind, diff --git a/desktop/src/features/channels/threadActivityStorage.ts b/desktop/src/features/channels/threadActivityStorage.ts index 4e7c65a872..2eafb4fd46 100644 --- a/desktop/src/features/channels/threadActivityStorage.ts +++ b/desktop/src/features/channels/threadActivityStorage.ts @@ -1,6 +1,11 @@ +import { KIND_STREAM_MESSAGE_EDIT } from "@/shared/constants/kinds"; import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; export type ThreadActivityItem = { + /** `createdAt` of the edit whose content this row currently shows. */ + contentEditedAt?: number; + /** Event id of that edit — the tie-break when timestamps match. */ + contentEditId?: string; id: string; kind: number; pubkey: string; @@ -94,6 +99,18 @@ export function writeActivityToStorage( } } +/** The `e` tag an edit addresses, if it has one. */ +function editTargetId(item: ThreadActivityItem): string | null { + if (item.kind !== KIND_STREAM_MESSAGE_EDIT) { + return null; + } + return ( + item.tags.find( + (tag) => tag[0] === "e" && typeof tag[1] === "string", + )?.[1] ?? null + ); +} + export function addThreadActivityItems( existing: ThreadActivityItem[], items: ThreadActivityItem[], @@ -102,13 +119,54 @@ export function addThreadActivityItems( return { didAdd: false, items: existing }; } - const existingIds = new Set(existing.map((item) => item.id)); - const newItems = items.filter((item) => !existingIds.has(item.id)); + // An edit is not a new activity row — it replaces the content of the row it + // targets. Activity rows otherwise keep the content they were created with, + // so a card updated after it landed in Home would show its original + // fallbackText forever while the detail view showed the current spec. + let overlaid = existing; + let didOverlay = false; + for (const item of items) { + const targetId = editTargetId(item); + if (!targetId) { + continue; + } + const index = overlaid.findIndex((row) => row.id === targetId); + if (index === -1 || overlaid[index].content === item.content) { + continue; + } + // Apply the same rule every other reader uses — newest `createdAt`, ties + // to the smallest id — instead of "last one delivered". A reconnect replays + // recent events newest-first, so arrival order would otherwise let an + // older edit revert a newer one. + const appliedAt = overlaid[index].contentEditedAt; + const appliedId = overlaid[index].contentEditId; + if ( + appliedAt !== undefined && + appliedId !== undefined && + (item.createdAt < appliedAt || + (item.createdAt === appliedAt && item.id > appliedId)) + ) { + continue; + } + overlaid = [...overlaid]; + overlaid[index] = { + ...overlaid[index], + content: item.content, + contentEditedAt: item.createdAt, + contentEditId: item.id, + }; + didOverlay = true; + } + + const existingIds = new Set(overlaid.map((item) => item.id)); + const newItems = items.filter( + (item) => !existingIds.has(item.id) && editTargetId(item) === null, + ); if (newItems.length === 0) { - return { didAdd: false, items: existing }; + return { didAdd: didOverlay, items: overlaid }; } - const merged = [...existing, ...newItems].sort( + const merged = [...overlaid, ...newItems].sort( (left, right) => left.createdAt - right.createdAt, ); const capped = diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 20eff07ea6..a7ac6ea851 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -61,7 +61,7 @@ import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; import type { TimelineMessage } from "@/features/messages/types"; import { isWelcomeExperienceChannel as isWelcomeExperience } from "@/features/onboarding/welcome"; -import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; +import { KIND_SURFACE, KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; @@ -270,6 +270,9 @@ export const ChannelPane = React.memo(function ChannelPane({ for (const message of candidates) { if ( message.kind === KIND_SYSTEM_MESSAGE || + // Surfaces are edited via full-spec replacement (CLI/SDK), not the + // inline composer — ArrowUp must not open a textarea full of JSON. + message.kind === KIND_SURFACE || message.pubkey !== currentPubkey || message.pending ) { diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index 800467b6ea..66be9f6299 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -10,7 +10,9 @@ import { } from "@/features/messages/lib/threading"; import { shouldNotifyForEvent } from "@/features/notifications/lib/shouldNotify"; import { relayClient } from "@/shared/api/relayClient"; +import { readActivityFromStorage } from "./threadActivityStorage"; import { + KIND_STREAM_MESSAGE_EDIT, CHANNEL_EVENT_KINDS, CHANNEL_MESSAGE_EVENT_KINDS, } from "@/shared/constants/kinds"; @@ -25,6 +27,8 @@ import { refreshChannelsWhenIdle } from "./refreshChannelsWhenIdle"; export type UseLiveChannelUpdatesOptions = { currentPubkey?: string; + /** Relay URL — scopes the persisted activity rows read at startup. */ + relayUrl?: string; /** * When true, DM notifications also fire for the channel the user is * currently viewing (normally suppressed). @@ -132,6 +136,81 @@ export function trackSeenEvent( return true; } +/** + * Apply edits that landed while the app was closed to the persisted activity + * rows. + * + * Activity rows are hydrated from local storage with the content they were + * stored with, the catch-up query deliberately excludes edits, and the live + * subscription starts at `since: now` — so a card edited during downtime would + * keep its pre-edit fallbackText in Home forever while the detail view showed + * the current spec. One targeted lookup per persisted row closes that window; + * results flow through the same activity merge as live edits. + */ +/** Rows reconciled at startup — newest first, bounded so a large + * activity history cannot fan out into hundreds of queries. */ +const CATCH_UP_ROW_CAP = 40; + +function useOfflineActivityEditCatchUp( + pubkey: string | undefined, + relayUrl: string | undefined, + onEdit: ((channelId: string, event: RelayEvent) => void) | undefined, +) { + // `onEdit` is rebuilt whenever the channel list changes; keep it in a ref so + // catch-up runs once per identity/relay scope rather than on every refresh. + const onEditRef = React.useRef(onEdit); + onEditRef.current = onEdit; + const doneScopeRef = React.useRef(null); + + React.useEffect(() => { + const normalizedPubkey = pubkey?.trim().toLowerCase(); + if (!normalizedPubkey || !relayUrl) return; + const scope = `${normalizedPubkey}:${relayUrl}`; + if (doneScopeRef.current === scope) return; + doneScopeRef.current = scope; + + let cancelled = false; + void (async () => { + // Storage keeps rows ascending by createdAt, so the NEWEST rows — the + // ones Home actually shows — are at the tail. + const rows = readActivityFromStorage(normalizedPubkey, relayUrl).slice( + -CATCH_UP_ROW_CAP, + ); + if (rows.length === 0) return; + try { + // One query per row, one row each: a shared OR-ed filter would let a + // heavily-edited card exhaust the budget and leave the others stale. + // `created_at DESC, id ASC` plus the smallest-id tie-break makes the + // first row the winner, so `limit: 1` is exact. + const results = await Promise.all( + rows.map((row) => + relayClient + .fetchEvents({ + kinds: [KIND_STREAM_MESSAGE_EDIT], + "#e": [row.id], + limit: 1, + }) + .then((events) => ({ row, events })) + .catch(() => ({ row, events: [] as RelayEvent[] })), + ), + ); + if (cancelled) return; + for (const { row, events } of results) { + for (const edit of events) { + onEditRef.current?.(row.channelId, edit); + } + } + } catch (error) { + console.error("Failed to catch up activity edits", error); + } + })(); + + return () => { + cancelled = true; + }; + }, [pubkey, relayUrl]); +} + export function useLiveChannelUpdates( channels: Channel[], activeChannelId: string | null, @@ -140,6 +219,12 @@ export function useLiveChannelUpdates( const queryClient = useQueryClient(); const normalizedCurrentPubkey = options.currentPubkey?.trim().toLowerCase() ?? ""; + + useOfflineActivityEditCatchUp( + options.currentPubkey, + options.relayUrl, + options.onThreadReplyNotification, + ); const seenMentionEventIdsRef = React.useRef(new Set()); // Reconnect replay overlaps each live filter by five seconds so no message is // lost at the boundary. Keep one shared guard for every notification side @@ -272,6 +357,13 @@ export function useLiveChannelUpdates( event.id, SEEN_NOTIFICATION_EVENT_LIMIT, ); + // An edit is not an unread trigger, so it never reaches the notification + // path below — but Home activity rows must still show current content, and + // the activity merge treats an edit as an overlay on the row it targets. + if (event.kind === KIND_STREAM_MESSAGE_EDIT) { + options.onThreadReplyNotification?.(channelId, event); + } + const isThreadedReply = isThreadReply(event.tags); // DM alerts and every other notification side effect share this delivery diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index a2376f9b7b..5b95e729c4 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -141,7 +141,6 @@ export function useUnreadChannels( const { pubkey, relayClient, - relayUrl: relayUrlOption, mutedChannelIds: mutedChannelIdsOption, ...liveUpdateOptions } = options; @@ -149,8 +148,8 @@ export function useUnreadChannels( const normalizedPubkey = pubkey?.toLowerCase() ?? null; // Scoped relay key for activity storage; empty string when relay not yet known // so rows from an unknown relay never load into the wrong community. - const normalizedRelayUrl = relayUrlOption - ? normalizeRelayUrl(relayUrlOption) + const normalizedRelayUrl = liveUpdateOptions.relayUrl + ? normalizeRelayUrl(liveUpdateOptions.relayUrl) : ""; // Single identity for the in-memory thread-activity buffer — computed once // per render and used at reset, both writers, and the return fence. The diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 1fb3c35cc4..8b168aef2f 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -1,3 +1,5 @@ +import SurfaceMessage from "@/features/surfaces/ui/SurfaceMessage"; +import { KIND_SURFACE } from "@/shared/constants/kinds"; import { MessageSquare } from "lucide-react"; import { useMemo } from "react"; @@ -118,13 +120,18 @@ export function ForumPostCard({
- + {post.kind === KIND_SURFACE ? ( + // Surfaces are data-only specs — render the card, never spec JSON. + + ) : ( + + )}
{summary && summary.replyCount > 0 ? ( diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index c6f1bfa6c1..9fd01270f0 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -15,6 +15,8 @@ import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; import { Button } from "@/shared/ui/button"; import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; import { Markdown } from "@/shared/ui/markdown"; +import { KIND_SURFACE } from "@/shared/constants/kinds"; +import SurfaceMessage from "@/features/surfaces/ui/SurfaceMessage"; import { Skeleton } from "@/shared/ui/skeleton"; import { formatRelativeTime } from "../lib/time"; @@ -111,14 +113,20 @@ function ReplyRow({ ) : null}
- + {reply.kind === KIND_SURFACE ? ( + // Surfaces are data-only specs — render the native card, never the + // markdown pipeline (which would print raw JSON). + + ) : ( + + )}
); @@ -254,14 +262,20 @@ export function ForumThreadPanel({ ) : null}
- + {post.kind === KIND_SURFACE ? ( + // A surface may be a thread root (NIP-SC §Event) — render the + // card, never spec JSON through markdown. + + ) : ( + + )}
diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index e34fa0c200..7a83623367 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -1,3 +1,5 @@ +import { surfacePreviewText } from "@/features/surfaces/spec"; +import { KIND_SURFACE } from "@/shared/constants/kinds"; import { resolveUserLabel, type UserProfileLookup, @@ -203,6 +205,11 @@ function feedHeadline(item: FeedItem, groupItems: readonly FeedItem[] = []) { } function feedPreview(item: FeedItem) { + // Surface events: show the spec's fallbackText, never raw JSON. + if (item.kind === KIND_SURFACE) { + return surfacePreviewText(item.content); + } + const content = item.content.trim(); if (content.length > 0) { return content; diff --git a/desktop/src/features/home/ui/FeedSection.tsx b/desktop/src/features/home/ui/FeedSection.tsx index 22fc177190..b8b836a21f 100644 --- a/desktop/src/features/home/ui/FeedSection.tsx +++ b/desktop/src/features/home/ui/FeedSection.tsx @@ -4,9 +4,11 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { surfacePreviewText } from "@/features/surfaces/spec"; import type { FeedItem } from "@/shared/api/types"; import { KIND_APPROVAL_REQUEST, + KIND_SURFACE, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_JOB_ACCEPTED, @@ -93,6 +95,12 @@ function feedHeadline(item: FeedItem) { } function feedContent(item: FeedItem) { + // Surfaces carry spec JSON as content — preview the human fallbackText, + // never the raw spec (and never through the markdown pipeline). + if (item.kind === KIND_SURFACE) { + return surfacePreviewText(item.content); + } + const content = item.content.trim(); if (content.length > 0) { return content; diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 9b6ff57e17..bdbef958a1 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -1,3 +1,4 @@ +import { messagePreviewText } from "@/features/surfaces/spec"; import { AlertCircle, ArrowLeft, @@ -229,6 +230,9 @@ function InboxMessageDetailPane({ authorPubkey: item.item.pubkey, avatarUrl: item.avatarUrl, content: item.preview, + // Carry the kind so the row renders a surface as plain text + // rather than falling through to the markdown pipeline. + kind: item.item.kind, createdAt: item.item.createdAt, depth: 0, fullTimestampLabel: item.fullTimestampLabel, @@ -392,7 +396,7 @@ function InboxMessageDetailPane({ replyTarget && replyTarget.id !== item.id ? { author: replyTarget.authorLabel, - body: replyTarget.content, + body: messagePreviewText(replyTarget.content, replyTarget.kind), id: replyTarget.id, } : null; diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index fa214dc730..6e9681c3ed 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,3 +1,4 @@ +import { KIND_SURFACE } from "@/shared/constants/kinds"; import { Bell, Clock, Ellipsis, ExternalLink, MailOpen } from "lucide-react"; import * as React from "react"; @@ -407,12 +408,22 @@ export function InboxListPane({ : "font-semibold text-foreground", )} > - + {item.item.kind === KIND_SURFACE ? ( + // A surface preview is the spec's plain fallbackText. Routing + // it through markdown would let an author reclaim + // presentation control (**bold**, headings, images) from + // inside a data-only payload — see docs/nips/NIP-SC.md. + + {item.preview} + + ) : ( + + )} diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 04deafb3ff..9d531d5fd5 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -16,6 +16,8 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { Markdown } from "@/shared/ui/markdown"; +import { KIND_SURFACE } from "@/shared/constants/kinds"; +import SurfaceMessage from "@/features/surfaces/ui/SurfaceMessage"; import { UserAvatar } from "@/shared/ui/UserAvatar"; export type InboxDisplayMessage = InboxContextMessage & { @@ -197,25 +199,29 @@ export function InboxMessageRow({ )}
- + {message.kind === KIND_SURFACE ? ( + + ) : ( + + )} [event.id, event])); const contextEventIds = new Set(eventById.keys()); - const contextReactions = [ + // Auxiliary overlays for the loaded context events: reactions AND edits. + // Edits matter for surfaces especially — a card's live updates are edit + // events, so without these the Inbox would render a stale card forever. + const contextAuxiliaries = [ ...(channelMessages ?? []), ...reactionEvents, ].filter((event) => { - if (event.kind !== KIND_REACTION) return false; + if ( + event.kind !== KIND_REACTION && + event.kind !== KIND_STREAM_MESSAGE_EDIT + ) { + return false; + } const targetId = getReactionTargetId(event.tags); return Boolean(targetId && contextEventIds.has(targetId)); }); @@ -52,7 +63,7 @@ export function useHomeInboxContextMessages({ ? (profiles?.[currentPubkey.toLowerCase()]?.avatarUrl ?? null) : null; const timelineMessages = formatTimelineMessages( - [...events, ...contextReactions], + [...events, ...contextAuxiliaries], selectedChannel, currentPubkey, currentUserAvatarUrl, diff --git a/desktop/src/features/home/useInboxThreadContext.ts b/desktop/src/features/home/useInboxThreadContext.ts index 67a46957f9..1451ba178d 100644 --- a/desktop/src/features/home/useInboxThreadContext.ts +++ b/desktop/src/features/home/useInboxThreadContext.ts @@ -4,7 +4,10 @@ import { isInboxThreadContextEvent } from "@/features/home/lib/inboxViewHelpers" import { relayEventFromFeedItem } from "@/features/home/lib/inbox"; import { getThreadReference } from "@/features/messages/lib/threading"; import { relayClient } from "@/shared/api/relayClient"; -import { buildChannelReactionAuxFilter } from "@/shared/api/relayChannelFilters"; +import { + buildChannelReactionAuxFilter, + buildChannelStructuralAuxFilter, +} from "@/shared/api/relayChannelFilters"; import { getEventById } from "@/shared/api/tauri"; import type { FeedItem, RelayEvent } from "@/shared/api/types"; import { @@ -250,8 +253,12 @@ export function useInboxThreadContext( selectedThreadRootId, ]); - // Reactions carry only an `#e` reference, so the channel-window cache never - // has them for thread replies — fetch them for the rendered context messages. + // Reactions AND structural aux (edits/deletions) carry only an `#e` + // reference, so the channel-window cache never has them for thread replies — + // fetch both for the rendered context messages. Edits matter especially for + // surface cards, whose whole update model is a full-spec replacement: without + // this backfill the Inbox renders the original card forever. Mirrors the + // structural-aux backfill the main thread reader documents. const [reactionEvents, setReactionEvents] = React.useState([]); const contextEventIdsKey = React.useMemo( () => @@ -271,14 +278,22 @@ export function useInboxThreadContext( } try { - return await relayClient.fetchAuxEventsByReference( - selectedChannelId, - eventIds, - buildChannelReactionAuxFilter, - ); + const [reactions, structural] = await Promise.all([ + relayClient.fetchAuxEventsByReference( + selectedChannelId, + eventIds, + buildChannelReactionAuxFilter, + ), + relayClient.fetchAuxEventsByReference( + selectedChannelId, + eventIds, + buildChannelStructuralAuxFilter, + ), + ]); + return [...reactions, ...structural]; } catch (error) { console.error( - "Failed to hydrate reactions for Inbox context messages", + "Failed to hydrate auxiliaries for Inbox context messages", selectedChannelId, error, ); diff --git a/desktop/src/features/local-archive/ui/localArchiveKinds.ts b/desktop/src/features/local-archive/ui/localArchiveKinds.ts index 0717559f71..ac13fe0fa7 100644 --- a/desktop/src/features/local-archive/ui/localArchiveKinds.ts +++ b/desktop/src/features/local-archive/ui/localArchiveKinds.ts @@ -73,6 +73,8 @@ function kindLabel(kind: number): string { return "Stream messages v2 (kind 40002)"; case 40003: return "Message edits (kind 40003)"; + case 40110: + return "Surface cards (kind 40110)"; case 45001: return "Forum posts (kind 45001)"; case 45003: diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs index 926738a60b..379540fa2b 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs +++ b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs @@ -674,3 +674,71 @@ test("CHANNEL_TIMELINE_CONTENT_KINDS matches isTimelineContentEvent", () => { ); } }); + +test("same-second edits resolve deterministically by id tie-break", () => { + const original = streamMessage({ created_at: 1_700_000_000 }); + // Two edits sharing one created_at (nostr timestamps are second-precision). + // The lexicographically-SMALLEST edit id wins on every client, regardless of + // arrival order. Smallest is not arbitrary: the relay orders + // `created_at DESC, id ASC`, so the winner under this rule is the first row a + // query returns — which is what lets readers resolve current state with a + // one-row lookup per target instead of fetching an unbounded edit history. + const editLowId = streamEdit(HEX64_A, "low-id edit", { + id: "1111111111111111111111111111111111111111111111111111111111111100", + created_at: 1_700_000_010, + }); + const editHighId = streamEdit(HEX64_A, "high-id edit", { + id: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00", + created_at: 1_700_000_010, + }); + + for (const order of [ + [original, editLowId, editHighId], + [original, editHighId, editLowId], + ]) { + const out = formatTimelineMessages(order, null, undefined, null); + assert.equal(out.length, 1, "one row, never duplicated by edits"); + assert.equal( + out[0].body, + "low-id edit", + "the smallest edit id must win the same-second tie in any arrival order", + ); + assert.equal(out[0].edited, true); + } +}); + +test("surface event renders as its own timeline row", () => { + const surface = streamMessage({ + kind: 40110, + content: JSON.stringify({ + version: 1, + fallbackText: "Deploy healthy", + nodes: [{ type: "badge", text: "HEALTHY", tone: "success" }], + }), + }); + assert.equal(isTimelineContentEvent(surface), true); + const out = formatTimelineMessages([surface], null, undefined, null); + assert.equal(out.length, 1, "surface renders as a timeline row"); + assert.equal(out[0].kind, 40110); +}); + +test("surface edit overlays the spec content on the surface row", () => { + const surface = streamMessage({ + kind: 40110, + content: JSON.stringify({ + version: 1, + fallbackText: "Deploy healthy", + nodes: [{ type: "badge", text: "HEALTHY", tone: "success" }], + }), + }); + const incident = JSON.stringify({ + version: 1, + fallbackText: "Deploy degraded", + nodes: [{ type: "badge", text: "DEGRADED", tone: "danger" }], + }); + const edit = streamEdit(HEX64_A, incident, { created_at: 1_700_000_010 }); + const out = formatTimelineMessages([surface, edit], null, undefined, null); + assert.equal(out.length, 1, "still a single row after the live update"); + assert.equal(out[0].body, incident, "edit replaces the whole spec"); + assert.equal(out[0].edited, true); +}); diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 640c12bb75..d57f553e43 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -34,6 +34,7 @@ import { KIND_STREAM_MESSAGE_V2, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_DIFF, + KIND_SURFACE, KIND_SYSTEM_MESSAGE, } from "@/shared/constants/kinds"; import { resolveEventAuthorPubkey } from "@/shared/lib/authors"; @@ -51,6 +52,7 @@ export function isTimelineContentEvent(event: RelayEvent) { event.kind === KIND_STREAM_MESSAGE || event.kind === KIND_STREAM_MESSAGE_V2 || event.kind === KIND_STREAM_MESSAGE_DIFF || + event.kind === KIND_SURFACE || event.kind === KIND_SYSTEM_MESSAGE || event.kind === KIND_JOB_REQUEST || event.kind === KIND_JOB_ACCEPTED || @@ -226,7 +228,7 @@ export function formatTimelineMessages( // the original (`h`, `p` mentions, etc.) stay untouched. const editsByTargetId = new Map< string, - { content: string; tags: string[][]; createdAt: number } + { content: string; tags: string[][]; createdAt: number; editId: string } >(); for (const event of events) { if ( @@ -241,12 +243,20 @@ export function formatTimelineMessages( continue; } + // Nostr timestamps are second-precision, so two edits can share a + // created_at. Break the tie on event id (lexicographic) — deterministic + // on every client, instead of load-order-dependent. const existing = editsByTargetId.get(targetId); - if (!existing || event.created_at > existing.createdAt) { + if ( + !existing || + event.created_at > existing.createdAt || + (event.created_at === existing.createdAt && event.id < existing.editId) + ) { editsByTargetId.set(targetId, { content: event.content, tags: event.tags, createdAt: event.created_at, + editId: event.id, }); } } diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 967e50f5d2..79be12c9e2 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -34,7 +34,7 @@ import { cn } from "@/shared/lib/cn"; import { copyTextToClipboard } from "@/shared/lib/clipboard"; import { emojiDisplayName } from "@/shared/lib/emojiName"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; -import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds"; +import { KIND_HUDDLE_STARTED, KIND_SURFACE } from "@/shared/constants/kinds"; import { Button } from "@/shared/ui/button"; import { DeleteMessageConfirmDialog } from "./DeleteMessageConfirmDialog"; import { @@ -136,7 +136,10 @@ function MoreActionsMenu({ } }} > - {onEdit ? ( + {/* Surfaces are edited by their author via the CLI/SDK as a + full-spec replacement — a markdown textarea full of JSON is not + an edit experience, so the inline action is hidden for them. */} + {onEdit && message.kind !== KIND_SURFACE ? ( { diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 688b5d5f0d..511e51c0a6 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -1,3 +1,4 @@ +import { messagePreviewText } from "@/features/surfaces/spec"; import * as React from "react"; import { AlertTriangle } from "lucide-react"; @@ -27,6 +28,7 @@ import { import { KIND_HUDDLE_STARTED, KIND_STREAM_MESSAGE_DIFF, + KIND_SURFACE, } from "@/shared/constants/kinds"; import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey"; import { cn } from "@/shared/lib/cn"; @@ -48,6 +50,9 @@ import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; const DiffMessage = React.lazy(() => import("./DiffMessage")); +const SurfaceMessage = React.lazy( + () => import("@/features/surfaces/ui/SurfaceMessage"), +); const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded")); export type ThreadDepthGuideAction = { @@ -177,7 +182,7 @@ export const MessageRow = React.memo( openReminder({ eventId: msg.id, channelId: channelId ?? "", - preview: msg.body.slice(0, 100), + preview: messagePreviewText(msg.body, msg.kind).slice(0, 100), authorPubkey: msg.pubkey ?? "", }); }, @@ -330,6 +335,18 @@ export const MessageRow = React.memo( /> ); + case KIND_SURFACE: + return ( + + Loading surface… +
+ } + > + + + ); case KIND_HUDDLE_STARTED: return ( - (event.kind === KIND_STREAM_MESSAGE || - event.kind === KIND_STREAM_MESSAGE_V2) && + // Conversational kinds, so an agent answering with a surface card is + // not silently dropped from the inline thread. Mirrors Rust's + // CONVERSATIONAL_KINDS. + CHANNEL_MESSAGE_CONVERSATIONAL_KINDS.has(event.kind) && event.created_at >= visibleAfter, ) .sort((left, right) => left.created_at - right.created_at); diff --git a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx index 29705425f6..3b887ae6ee 100644 --- a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx +++ b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx @@ -1,3 +1,5 @@ +import SurfaceMessage from "@/features/surfaces/ui/SurfaceMessage"; +import { KIND_SURFACE } from "@/shared/constants/kinds"; import { EditorContent } from "@tiptap/react"; import { ALargeSmall, @@ -175,7 +177,7 @@ function useAgentCandidates() { } /** Live message feed for the conversation's backing DM channel, reduced to - * plain chat rows (kind 9 / 40002 only). */ + * plain chat rows (conversational kinds (9 / 40002 / surfaces)). */ function ConversationThread({ channel, agent, @@ -228,12 +230,18 @@ function ConversationThread({ {isSelf ? "You" : agent.name} - + {event.kind === KIND_SURFACE ? ( + // Surfaces are data-only specs — render the native card. The + // markdown pipeline would dump raw spec JSON here. + + ) : ( + + )} ); diff --git a/desktop/src/features/surfaces/spec.test.mjs b/desktop/src/features/surfaces/spec.test.mjs new file mode 100644 index 0000000000..05185a807d --- /dev/null +++ b/desktop/src/features/surfaces/spec.test.mjs @@ -0,0 +1,229 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { formatScalar, messagePreviewText, parseSurfaceSpec } from "./spec.ts"; + +const validSpec = JSON.stringify({ + version: 1, + fallbackText: "Deploy v2.4.1: 2/2 pods running", + title: "Deployment — api-gateway", + nodes: [ + { type: "badge", text: "HEALTHY", tone: "success" }, + { type: "heading", text: "Pods" }, + { type: "text", text: "All pods running." }, + { + type: "keyValue", + items: [{ label: "Version", value: "v2.4.1", tone: "info" }], + }, + { + type: "statGrid", + stats: [ + { label: "Pods", value: 2, delta: "+1", tone: "success" }, + { label: "Errors", value: 0 }, + ], + }, + { + type: "table", + columns: ["Pod", "Status"], + rows: [ + ["web-7d9f", "Running"], + ["web-a1c2", 3], + ], + }, + { type: "progress", label: "Rollout", value: 100 }, + ], +}); + +test("parseSurfaceSpec_validSpec_allNodesInOrder", () => { + const result = parseSurfaceSpec(validSpec); + assert.equal(result.outcome, "card"); + assert.equal(result.spec.nodes.length, 7); + assert.deepEqual( + result.spec.nodes.map((n) => n.type), + ["badge", "heading", "text", "keyValue", "statGrid", "table", "progress"], + ); + assert.equal(result.spec.title, "Deployment — api-gateway"); +}); + +test("parseSurfaceSpec_numericValues_keptAndFormatted", () => { + const result = parseSurfaceSpec(validSpec); + assert.equal(result.outcome, "card"); + const statGrid = result.spec.nodes.find((n) => n.type === "statGrid"); + assert.equal(statGrid.stats[0].value, 2); + assert.equal(formatScalar(statGrid.stats[0].value), "2"); + const table = result.spec.nodes.find((n) => n.type === "table"); + assert.equal(formatScalar(table.rows[1][1]), "3"); +}); + +test("parseSurfaceSpec_unknownTone_coercesToDefault", () => { + const result = parseSurfaceSpec( + JSON.stringify({ + version: 1, + fallbackText: "x", + nodes: [{ type: "badge", text: "B", tone: "sparkly" }], + }), + ); + assert.equal(result.outcome, "card"); + assert.equal(result.spec.nodes[0].tone, "default"); +}); + +test("parseSurfaceSpec_progress_clampedTo0100", () => { + const result = parseSurfaceSpec( + JSON.stringify({ + version: 1, + fallbackText: "x", + nodes: [ + { type: "progress", value: 250 }, + { type: "progress", value: -10 }, + ], + }), + ); + assert.equal(result.outcome, "card"); + assert.equal(result.spec.nodes[0].value, 100); + assert.equal(result.spec.nodes[1].value, 0); +}); + +test("parseSurfaceSpec_invalidNode_dropsButSiblingsSurvive", () => { + const result = parseSurfaceSpec( + JSON.stringify({ + version: 1, + fallbackText: "x", + nodes: [ + { type: "badge", text: "OK", tone: "success" }, + { type: "iframe", src: "https://evil.example" }, + { type: "table", columns: ["A"], rows: [["ragged", "extra"]] }, + { type: "text", text: "still here" }, + ], + }), + ); + assert.equal(result.outcome, "card"); + assert.deepEqual( + result.spec.nodes.map((n) => n.type), + ["badge", "text"], + ); +}); + +test("parseSurfaceSpec_zeroValidNodes_fallsBackToFallbackText", () => { + const result = parseSurfaceSpec( + JSON.stringify({ + version: 1, + fallbackText: "Deployment 80% complete", + nodes: [{ type: "iframe", src: "https://evil.example" }], + }), + ); + assert.deepEqual(result, { + outcome: "fallback", + text: "Deployment 80% complete", + }); +}); + +test("parseSurfaceSpec_unknownVersion_fallsBackToFallbackText", () => { + const result = parseSurfaceSpec( + JSON.stringify({ + version: 2, + fallbackText: "future card", + nodes: [{ type: "badge", text: "B" }], + }), + ); + assert.deepEqual(result, { outcome: "fallback", text: "future card" }); +}); + +test("parseSurfaceSpec_brokenJson_isRaw", () => { + assert.deepEqual(parseSurfaceSpec("{not json"), { outcome: "raw" }); +}); + +test("parseSurfaceSpec_missingFallbackText_isRaw", () => { + const result = parseSurfaceSpec( + JSON.stringify({ version: 1, nodes: [{ type: "text", text: "y" }] }), + ); + assert.deepEqual(result, { outcome: "raw" }); +}); + +test("parseSurfaceSpec_nonScalarCell_dropsTableOnly", () => { + const result = parseSurfaceSpec( + JSON.stringify({ + version: 1, + fallbackText: "x", + nodes: [ + { type: "table", columns: ["A"], rows: [[true]] }, + { type: "badge", text: "B" }, + ], + }), + ); + assert.equal(result.outcome, "card"); + assert.deepEqual( + result.spec.nodes.map((n) => n.type), + ["badge"], + ); +}); + +test("parseSurfaceSpec_nodesBeyondCap_truncatedNotFatal", () => { + const nodes = Array.from({ length: 40 }, (_, i) => ({ + type: "text", + text: `node ${i}`, + })); + const result = parseSurfaceSpec( + JSON.stringify({ version: 1, fallbackText: "x", nodes }), + ); + assert.equal(result.outcome, "card"); + assert.equal(result.spec.nodes.length, 32); +}); + +test("parseSurfaceSpec_envelopeInvalid_salvagesUsableFallbackText", () => { + // Future/restructured envelope (nodes not an array) still has a usable + // fallbackText — the matrix prefers it over raw JSON. + const result = parseSurfaceSpec( + JSON.stringify({ + version: 3, + fallbackText: "Deploy status: healthy", + nodes: { restructured: true }, + }), + ); + assert.deepEqual(result, { + outcome: "fallback", + text: "Deploy status: healthy", + }); +}); + +test("parseSurfaceSpec_whitespaceFallbackText_isRawNotBlank", () => { + const result = parseSurfaceSpec( + JSON.stringify({ + version: 1, + fallbackText: " ", + nodes: [{ type: "text", text: "y" }], + }), + ); + assert.deepEqual(result, { outcome: "raw" }); +}); + +test("parseSurfaceSpec_astralPlaneLengths_countCodePoints", () => { + // 300 emoji = 300 code points (relay-valid) but 600 UTF-16 units — the + // node must survive, matching the relay's chars().count() gate. + const emoji = "🐝".repeat(300); + const result = parseSurfaceSpec( + JSON.stringify({ + version: 1, + fallbackText: "x", + nodes: [{ type: "heading", text: emoji }], + }), + ); + assert.equal(result.outcome, "card"); + assert.equal(result.spec.nodes.length, 1); +}); + +test("messagePreviewText_surfaceKind_returnsFallbackNotJson", () => { + // Reply banners, reminder previews and notifications show a message body + // outside its own renderer — spec JSON must never reach a human there. + const spec = JSON.stringify({ + version: 1, + fallbackText: "Deploy healthy", + nodes: [{ type: "badge", text: "HEALTHY", tone: "success" }], + }); + assert.equal(messagePreviewText(spec, 40110), "Deploy healthy"); + assert.ok(!messagePreviewText(spec, 40110).includes("{")); +}); + +test("messagePreviewText_otherKinds_passThroughUnchanged", () => { + assert.equal(messagePreviewText("hello **world**", 9), "hello **world**"); + assert.equal(messagePreviewText("plain", undefined), "plain"); +}); diff --git a/desktop/src/features/surfaces/spec.ts b/desktop/src/features/surfaces/spec.ts new file mode 100644 index 0000000000..17f6b2064c --- /dev/null +++ b/desktop/src/features/surfaces/spec.ts @@ -0,0 +1,245 @@ +import { z } from "zod"; + +// SurfaceSpec v1 — tolerant client-side parser. +// +// The relay validates strictly at ingest (buzz-core/src/surface.rs); this +// parser is deliberately tolerant for historical/foreign events: unknown +// tones coerce to "default", numeric cells are kept as numbers and rendered +// as text, invalid nodes drop individually while their siblings survive. +// Anything worse falls back to `fallbackText`, then to escaped raw content — +// never through the markdown pipeline, never a blank or error row. +// +// Structural limits mirror buzz-core; a node that exceeds them is treated as +// invalid (dropped) rather than truncated, so both gates agree on what a +// valid node is. + +export const SURFACE_MAX_NODES = 32; +const MAX_SCALAR = 512; +const MAX_TEXT = 4096; +const MAX_ITEMS = 32; +const MAX_COLUMNS = 12; +const MAX_ROWS = 100; + +const TONES = ["default", "success", "warning", "danger", "info"] as const; +export type SurfaceTone = (typeof TONES)[number]; + +const toneSchema = z.enum(TONES).catch("default"); + +const finiteNumber = z.number().refine(Number.isFinite); + +// Length limits count Unicode code points ([...s].length), matching the +// relay's chars().count() — JS .length counts UTF-16 code units and would +// reject relay-valid strings containing astral-plane characters (emoji). +const maxCodePoints = (max: number) => (s: string) => [...s].length <= max; + +const scalarSchema = z.union([ + z.string().refine(maxCodePoints(MAX_SCALAR)), + finiteNumber, +]); + +const nonBlank = (max: number) => + z + .string() + .refine(maxCodePoints(max)) + .refine((s) => s.trim().length > 0); + +const headingSchema = z.object({ + type: z.literal("heading"), + text: nonBlank(MAX_SCALAR), +}); + +const textSchema = z.object({ + type: z.literal("text"), + text: nonBlank(MAX_TEXT), +}); + +const badgeSchema = z.object({ + type: z.literal("badge"), + text: nonBlank(MAX_SCALAR), + tone: toneSchema.default("default"), +}); + +const keyValueSchema = z.object({ + type: z.literal("keyValue"), + items: z + .array( + z.object({ + label: nonBlank(MAX_SCALAR), + value: scalarSchema, + tone: toneSchema.default("default"), + }), + ) + .min(1) + .max(MAX_ITEMS), +}); + +const statGridSchema = z.object({ + type: z.literal("statGrid"), + stats: z + .array( + z.object({ + label: nonBlank(MAX_SCALAR), + value: scalarSchema, + delta: scalarSchema.optional(), + tone: toneSchema.default("default"), + }), + ) + .min(1) + .max(MAX_ITEMS), +}); + +const tableSchema = z + .object({ + type: z.literal("table"), + columns: z.array(nonBlank(MAX_SCALAR)).min(1).max(MAX_COLUMNS), + rows: z.array(z.array(scalarSchema)).max(MAX_ROWS), + }) + .refine((t) => t.rows.every((row) => row.length === t.columns.length)); + +const progressSchema = z.object({ + type: z.literal("progress"), + label: nonBlank(MAX_SCALAR).optional(), + // Client clamps to 0–100 (§3.7); the relay only requires finite. + value: finiteNumber.transform((v) => Math.min(100, Math.max(0, v))), +}); + +const nodeSchema = z.discriminatedUnion("type", [ + headingSchema, + textSchema, + badgeSchema, + keyValueSchema, + statGridSchema, + tableSchema, + progressSchema, +]); + +export type SurfaceNode = z.infer; + +export interface SurfaceSpec { + title?: string; + fallbackText: string; + nodes: SurfaceNode[]; +} + +// Envelope shape probed before node-level salvage. Extra fields are ignored +// (tolerant), version is checked by hand so unknown versions can still route +// to the fallbackText path. +const envelopeSchema = z.object({ + version: z.number(), + fallbackText: z + .string() + .refine(maxCodePoints(1024)) + .refine((s) => s.trim().length > 0), + title: z.string().refine(maxCodePoints(512)).optional(), + nodes: z.array(z.unknown()), +}); + +// Salvage a usable fallbackText from an envelope that failed schema parse +// (unknown future shape, nodes not an array, etc.). The fallback matrix +// prefers plain fallbackText over raw JSON whenever one is present. +function salvageFallbackText(json: unknown): string | null { + if (typeof json !== "object" || json === null) { + return null; + } + const value = (json as { fallbackText?: unknown }).fallbackText; + if (typeof value !== "string" || value.trim().length === 0) { + return null; + } + return [...value].length <= 1024 ? value : null; +} + +export type SurfaceParseResult = + | { outcome: "card"; spec: SurfaceSpec } + | { outcome: "fallback"; text: string } + | { outcome: "raw" }; + +/** + * Parse surface event content per the v1 fallback matrix. + * + * - valid v1 spec → `card` (invalid nodes dropped, survivors render) + * - unknown version / zero valid nodes → `fallback` with `fallbackText` + * - unparseable JSON / missing fallbackText → `raw` (caller shows escaped + * plain text — NEVER the markdown pipeline) + */ +export function parseSurfaceSpec(raw: string): SurfaceParseResult { + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + return { outcome: "raw" }; + } + + const envelope = envelopeSchema.safeParse(json); + if (!envelope.success) { + const salvaged = salvageFallbackText(json); + return salvaged + ? { outcome: "fallback", text: salvaged } + : { outcome: "raw" }; + } + + if (envelope.data.version !== 1) { + return { outcome: "fallback", text: envelope.data.fallbackText }; + } + + const nodes: SurfaceNode[] = []; + for (const rawNode of envelope.data.nodes.slice(0, SURFACE_MAX_NODES)) { + const parsed = nodeSchema.safeParse(rawNode); + if (parsed.success) { + nodes.push(parsed.data); + } + } + + if (nodes.length === 0) { + return { outcome: "fallback", text: envelope.data.fallbackText }; + } + + const title = envelope.data.title?.trim(); + return { + outcome: "card", + spec: { + title: title ? title : undefined, + fallbackText: envelope.data.fallbackText, + nodes, + }, + }; +} + +/** Render a scalar cell value for display. */ +export function formatScalar(value: string | number): string { + return typeof value === "number" ? String(value) : value; +} + +/** True when the value should render with tabular-nums (numeric column). */ +export function isNumeric(value: string | number): boolean { + return typeof value === "number"; +} + +/** + * Plain-text preview for a surface event's content — for notification + * bodies, home-feed previews, and any other single-line context. Returns + * `fallbackText` when the envelope carries one, else a generic label. + * Never returns raw spec JSON. + */ +export function surfacePreviewText(content: string): string { + const parsed = parseSurfaceSpec(content); + switch (parsed.outcome) { + case "card": + return parsed.spec.fallbackText; + case "fallback": + return parsed.text; + case "raw": + return "Surface card"; + } +} + +/** + * Plain-text preview for any message body, given its kind. Surfaces render + * their `fallbackText`; every other kind is returned unchanged. + * + * Use this anywhere a message body is shown outside its own renderer — reply + * banners, reminder previews, notifications — so spec JSON never leaks into a + * human-facing string. + */ +export function messagePreviewText(body: string, kind?: number): string { + return kind === 40110 ? surfacePreviewText(body) : body; +} diff --git a/desktop/src/features/surfaces/ui/SurfaceCard.tsx b/desktop/src/features/surfaces/ui/SurfaceCard.tsx new file mode 100644 index 0000000000..2a71799b76 --- /dev/null +++ b/desktop/src/features/surfaces/ui/SurfaceCard.tsx @@ -0,0 +1,260 @@ +import * as React from "react"; + +import type { + SurfaceNode, + SurfaceSpec, + SurfaceTone, +} from "@/features/surfaces/spec"; +import { formatScalar, isNumeric } from "@/features/surfaces/spec"; +import { cn } from "@/shared/lib/cn"; +import { Progress } from "@/shared/ui/progress"; +import { useSmoothCorners } from "@/shared/ui/smoothCorners"; + +// Pure presentational renderer for a parsed SurfaceSpec. All content is +// data-only plain text — no markdown, no links, no media. Tone is expressed +// with color plus a distinct per-tone glyph (never color alone). + +const badgeToneClass: Record = { + default: "bg-muted text-muted-foreground", + success: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400", + warning: "bg-amber-500/15 text-amber-600 dark:text-amber-400", + danger: "bg-red-500/15 text-red-600 dark:text-red-400", + info: "bg-sky-500/15 text-sky-600 dark:text-sky-400", +}; + +const textToneClass: Record = { + default: "text-foreground", + success: "text-emerald-600 dark:text-emerald-400", + warning: "text-amber-600 dark:text-amber-400", + danger: "text-red-600 dark:text-red-400", + info: "text-sky-600 dark:text-sky-400", +}; + +// Distinct per-tone glyphs so tone survives without color perception +// (WCAG use-of-color): success check, warning bang, danger cross, info i. +const toneGlyph: Record = { + default: null, + success: "✓", + warning: "!", + danger: "✕", + info: "i", +}; + +function ToneGlyph({ tone }: { tone: SurfaceTone }) { + const glyph = toneGlyph[tone]; + if (glyph === null) { + return null; + } + return ( + <> + + {glyph} + + ({tone}) + + ); +} + +function ToneBadge({ text, tone }: { text: string; tone: SurfaceTone }) { + return ( + + + {text} + + ); +} + +function NodeView({ node }: { node: SurfaceNode }) { + switch (node.type) { + case "heading": + return ( +

+ {node.text} +

+ ); + case "text": + return ( +

+ {node.text} +

+ ); + case "badge": + return ; + case "keyValue": + return ( +
+ {node.items.map((item, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: spec is an immutable document — an edit replaces the whole spec, labels may duplicate + +
{item.label}
+
+ + {formatScalar(item.value)} +
+
+ ))} +
+ ); + case "statGrid": + return ( +
+ {node.stats.map((stat, i) => ( +
+
+ {stat.label} +
+
+ + {formatScalar(stat.value)} +
+ {stat.delta !== undefined && ( +
+ {formatScalar(stat.delta)} +
+ )} +
+ ))} +
+ ); + case "table": + return ( +
+ + + + {node.columns.map((col, i) => ( + + ))} + + + + {node.rows.map((row, r) => ( + + {row.map((cell, c) => ( + + ))} + + ))} + +
+ {col} +
+ {formatScalar(cell)} +
+
+ ); + case "progress": + return ( +
+
+ {node.label !== undefined && ( + {node.label} + )} + + {Math.round(node.value)}% + +
+ +
+ ); + } +} + +export default function SurfaceCard({ spec }: { spec: SurfaceSpec }) { + const cardRef = React.useRef(null); + useSmoothCorners(cardRef); + + // Consecutive badges flow onto one row, matching how authors use them. + const groups: SurfaceNode[][] = []; + for (const node of spec.nodes) { + const last = groups[groups.length - 1]; + if (node.type === "badge" && last?.[0]?.type === "badge") { + last.push(node); + } else { + groups.push([node]); + } + } + + return ( +
+
+ {spec.title !== undefined && ( +
+ {spec.title} +
+ )} + {groups.map((group, i) => + group.length > 1 ? ( + // biome-ignore lint/suspicious/noArrayIndexKey: spec is an immutable document — node groups are positional +
+ {group.map((node, j) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: spec is an immutable document — badges are positional + + ))} +
+ ) : ( + // biome-ignore lint/suspicious/noArrayIndexKey: spec is an immutable document — node groups are positional + + ), + )} +
+
+ ); +} diff --git a/desktop/src/features/surfaces/ui/SurfaceMessage.tsx b/desktop/src/features/surfaces/ui/SurfaceMessage.tsx new file mode 100644 index 0000000000..4a94ac4c86 --- /dev/null +++ b/desktop/src/features/surfaces/ui/SurfaceMessage.tsx @@ -0,0 +1,33 @@ +import { parseSurfaceSpec } from "@/features/surfaces/spec"; +import SurfaceCard from "@/features/surfaces/ui/SurfaceCard"; + +// Timeline entry point for a surface event: parse tolerantly and render per +// the v1 fallback matrix. Every failure path is plain escaped text — never +// the markdown pipeline (markdown would reopen link/media behavior the +// data-only model closes), never a blank or error row. +export default function SurfaceMessage({ content }: { content: string }) { + const parsed = parseSurfaceSpec(content); + + switch (parsed.outcome) { + case "card": + return ; + case "fallback": + return ( +

+ {parsed.text} +

+ ); + case "raw": + return ( +

+ {content} +

+ ); + } +} diff --git a/desktop/src/shared/constants/kinds.test.mjs b/desktop/src/shared/constants/kinds.test.mjs index e842574f99..5cd4c1df8c 100644 --- a/desktop/src/shared/constants/kinds.test.mjs +++ b/desktop/src/shared/constants/kinds.test.mjs @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + CHANNEL_MESSAGE_CONVERSATIONAL_KINDS, + CHANNEL_MESSAGE_EVENT_KINDS, isConversationalUnreadKind, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, @@ -72,3 +74,35 @@ test("isConversationalUnreadKind_unknownKind_countsAsConversational", () => { // (e.g. a future conversational kind) is kept. assert.equal(isConversationalUnreadKind(12345), true); }); + +test("isConversationalUnreadKind_surface_counts", () => { + // Surfaces are conversational content: they must trigger unread dots, + // home-feed rows, and mention counts exactly like a kind-9 message. + assert.equal(isConversationalUnreadKind(40110), true); +}); + +test("conversationalKinds_matchRustCONVERSATIONAL_KINDS", () => { + // Parity with `CONVERSATIONAL_KINDS` in crates/buzz-core/src/kind.rs. + // Every read path that treats kind:9 as "a message someone wrote" must treat + // surfaces the same way — on both sides of the Rust/TS boundary. + const RUST_CONVERSATIONAL_KINDS = [9, 40002, 40110]; + for (const kind of RUST_CONVERSATIONAL_KINDS) { + assert.ok( + CHANNEL_MESSAGE_EVENT_KINDS.includes(kind), + `kind ${kind} is conversational in Rust but missing from CHANNEL_MESSAGE_EVENT_KINDS`, + ); + // The exported Set is what readers actually branch on (Projects inline + // chat, and anything else asking "is this a message?"), so assert it too — + // otherwise a kind could silently drop out of it with tests still green. + assert.ok( + CHANNEL_MESSAGE_CONVERSATIONAL_KINDS.has(kind), + `kind ${kind} missing from CHANNEL_MESSAGE_CONVERSATIONAL_KINDS`, + ); + assert.equal(isConversationalUnreadKind(kind), true); + } + assert.equal( + CHANNEL_MESSAGE_CONVERSATIONAL_KINDS.size, + RUST_CONVERSATIONAL_KINDS.length, + "the conversational set must mirror Rust's CONVERSATIONAL_KINDS exactly", + ); +}); diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index ef3234f4c5..d75180d70d 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -20,6 +20,9 @@ export const KIND_STREAM_MESSAGE_EDIT = 40003; export const KIND_CHANNEL_THREAD_SUMMARY = 39005; export const KIND_CHANNEL_WINDOW_BOUNDS = 39006; export const KIND_STREAM_MESSAGE_DIFF = 40008; +// Surface card — versioned data-only UI spec rendered as a native card. +// Mirror of buzz-core's KIND_SURFACE (number pending assignment on #2480). +export const KIND_SURFACE = 40110; export const KIND_REMINDER = 40007; export const KIND_SYSTEM_MESSAGE = 40099; export const KIND_JOB_REQUEST = 43001; @@ -78,10 +81,18 @@ export const KIND_DM_VISIBILITY = 30622; export const CHANNEL_MESSAGE_EVENT_KINDS = [ KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, + KIND_SURFACE, // 40110 — surface cards are conversational content KIND_FORUM_POST, KIND_FORUM_COMMENT, ] as const; +// Kinds that are a human-visible message someone wrote — the conversational +// set. Mirrors `CONVERSATIONAL_KINDS` in crates/buzz-core/src/kind.rs (parity +// asserted in kinds.test.mjs). Use this wherever a reader asks "is this a +// message?", so a new content kind lands everywhere at once. +export const CHANNEL_MESSAGE_CONVERSATIONAL_KINDS: ReadonlySet = + new Set([KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, KIND_SURFACE]); + // Keep this in sync with the Home-feed mention query in buzz-db. export const HOME_MENTION_EVENT_KINDS = [...CHANNEL_MESSAGE_EVENT_KINDS]; @@ -126,6 +137,7 @@ export const CHANNEL_TIMELINE_CONTENT_KINDS = [ KIND_STREAM_MESSAGE, // 9 KIND_STREAM_MESSAGE_V2, // 40002 KIND_STREAM_MESSAGE_DIFF, // 40008 — diff messages (own row) + KIND_SURFACE, // 40110 — surface cards (own row) KIND_SYSTEM_MESSAGE, // 40099 — system rows (join/leave/channel-created) KIND_JOB_REQUEST, // 43001 KIND_JOB_ACCEPTED, // 43002 diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 355ccea9fc..a5b4c1b807 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -46,6 +46,7 @@ import { KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, KIND_STREAM_MESSAGE_EDIT, + KIND_SURFACE, KIND_SYSTEM_MESSAGE, KIND_TEXT_NOTE, KIND_USER_STATUS, @@ -4478,6 +4479,7 @@ const TIMELINE_KINDS = new Set([ 9, 40002, 40008, + KIND_SURFACE, 40099, 43001, 43002, diff --git a/docs/nips/NIP-SC.md b/docs/nips/NIP-SC.md new file mode 100644 index 0000000000..01e32b3ae4 --- /dev/null +++ b/docs/nips/NIP-SC.md @@ -0,0 +1,240 @@ +NIP-SC +====== + +Surface Cards +------------- + +`draft` `optional` `relay` + +**Depends on**: NIP-01 (basic event format, filters), NIP-10 (thread markers), NIP-29 (relay-based groups) + +## Abstract + +This NIP defines the **surface card**: a channel-scoped event whose content is a +small, versioned, **data-only** JSON document that clients render as a native +card. A surface describes *what* to show — headings, text, badges, key/value +lists, stat grids, tables, progress bars — never *how* to lay it out, and never +executable markup. The author (human via CLI, or an agent) controls content; +the client owns presentation. + +Because a surface is an ordinary signed event, it works the same whether the +author is a frontier model, a tiny local model, or a person, and it inherits the +existing NIP-29 scoping, membership auth, and audit trail. Live updates reuse +the existing edit kind (`kind:40003`) with full-spec replacement, so a single +card can move through states — healthy → incident → recovered — inside one +message row. + +## Motivation + +Agents increasingly need to present *structured* status inside a conversation: +a deploy dashboard, an incident summary, a checklist, a metrics panel. Two +existing options are both wrong: + +- **Markdown in a `kind:9` message.** Markdown is a presentation language with + links, images, and (via some renderers) HTML. Letting an agent emit markdown + to describe a status panel reopens exactly the link/media/script surface a + data-only model is trying to close, and it renders inconsistently across + clients. +- **A general "generative UI" payload** (arbitrary component trees, embedded + scripts, or a third-party UI runtime). This couples a durable signed event to + a fast-moving vendor format and hands the author control over layout and + behavior — the opposite of what a shared, auditable workspace wants. + +A surface card is the narrow middle: a fixed, versioned vocabulary of display +nodes carrying only data. Clients that do not implement this NIP ignore the +kind entirely (zero breaking changes); clients that do render a native card, and +on any parse failure fall back to a plain-text summary the author always +provides. + +## Non-Goals + +- **No interactivity in v1.** Buttons, forms, and inputs are out of scope. A + future revision MAY add an `actions` node whose only effect is to prefill a + client composer with a proposed reply; it MUST never execute code, carry a + URL, or auto-send. +- **No layout control.** The author cannot set widths, colors (beyond the fixed + semantic tone set), positions, or fonts. Rendering is entirely the client's. +- **No streaming.** Events arrive whole; there is no partial-spec/patch wire + form. Updates are full-spec replacements (§Updates). +- **No new transport.** A surface is a normal event on the existing submit and + query surfaces. This NIP adds no endpoint and no envelope. +- **Not searchable.** Surface content is machine-generated JSON; relays SHOULD + exclude the surface kind from full-text search so spec keys do not pollute the + index. (A future revision MAY index `fallbackText` only.) + +## Terminology + +This document uses MUST, MUST NOT, SHOULD, MAY, and RECOMMENDED as defined in +RFC 2119. + +- **surface event**: A stored, signed event of the surface kind whose `content` + is a canonical `SurfaceSpec` JSON document. +- **spec**: The `SurfaceSpec` document — the parsed `content`. +- **node**: One entry in the spec's `nodes` array; a single display element. +- **tone**: A semantic status enum applied to some nodes: + `default | success | warning | danger | info`. Tone selects a client theme + token; it is never a raw color. +- **scalar**: A cell/value primitive — a JSON string or a finite JSON number. + Booleans, `null`, objects, and arrays are not scalars. + +## Event + +- **kind**: `40110` (proposed; the maintainer assigns the final number). Regular, + stored, non-replaceable — the same storage class as a stream message. +- **content**: canonical `SurfaceSpec` JSON (§Spec). +- **tags**: + - `["h", ""]` — REQUIRED. The channel, per NIP-29. + - NIP-10 thread markers — OPTIONAL. A surface MAY be a thread root or a reply. + - `["p", ""]` — OPTIONAL explicit mentions. + +A surface is conversational content: relays and clients MUST treat it like a +`kind:9` message for unread state, home/activity feeds, and mention matching. + +## Spec + +```jsonc +{ + "version": 1, + "fallbackText": "Deploy v2.4.1: 2/2 pods running, rollout 100%", + "title": "Deployment — api-gateway", + "nodes": [ /* 1..32 nodes, rendered in order */ ] +} +``` + +- `version` — REQUIRED integer, MUST be `1`. +- `fallbackText` — REQUIRED, non-empty plain text, ≤ 512 characters. This is what + non-rendering clients and every failure path display. It is the author's + contract that the card degrades to a meaningful sentence. +- `title` — OPTIONAL plain text, ≤ 256 characters. +- `nodes` — REQUIRED array, 1–32 entries. + +Character counts are **Unicode scalar values** (code points), not UTF-16 code +units or bytes, so the same string is judged identically by a Rust relay and a +JavaScript client. + +### Node catalog (v1) + +```jsonc +{"type": "heading", "text": "Section heading"} +{"type": "text", "text": "Free-form paragraph (plain text, never markdown)"} +{"type": "badge", "text": "2/2 RUNNING", "tone": "success"} +{"type": "keyValue", "items": [{"label": "Version", "value": "v1.2.3", "tone": "info"}]} +{"type": "statGrid", "stats": [{"label": "Pods", "value": 2, "delta": "+1", "tone": "success"}]} +{"type": "table", "columns": ["Pod", "Status"], "rows": [["web-7d9f", "Running"]]} +{"type": "progress", "label": "Rollout", "value": 80} +``` + +`tone` is OPTIONAL everywhere it appears and defaults to `default`. + +### Structural limits + +| Field | Limit | +|---|---| +| canonical `content` | ≤ 32 KiB | +| `nodes` | 1–32 | +| `title` | ≤ 256 chars | +| `fallbackText` | 1–512 chars | +| `text` node body | ≤ 4096 chars | +| any label / value / cell / delta | ≤ 512 chars | +| `keyValue.items`, `statGrid.stats` | ≤ 32 each | +| `table` | ≤ 12 columns, ≤ 100 rows | +| cell / value type | string or finite number only | +| `progress.value` | finite number; clients clamp to 0–100 | + +The 32 KiB content cap exists because a Nostr relay's frame limit bounds the +whole event (tags + signature included); 32 KiB leaves generous headroom for +data-heavy tables while keeping worst-case render work bounded. + +## Canonicalization + +Producers MUST serialize the spec **canonically** before signing: stable field +order and no insignificant whitespace, so byte-identical specs produce +byte-identical content (and event ids). Producers SHOULD accept and normalize +known field aliases (e.g. a `table` node's `fields`/`headers` → `columns`) +*before* canonicalizing, so authored convenience never reaches the wire. + +## Relay behavior (strict gate) + +For a surface event, and for any `kind:40003` edit whose target is a surface, +the relay MUST: + +1. Apply the same auth as a stream message: messages-write scope, channel + membership, and the required `h` scope. +2. Parse `content` as a `SurfaceSpec` and reject on any of: unknown top-level + or node field, unknown `type`, unknown `tone`, a non-scalar or non-finite + cell/value, a table row whose length ≠ the column count, `version` ≠ 1, any + structural-limit violation, or content exceeding 32 KiB. + +Rejection MUST carry a field-specific reason (e.g. +`nodes[3].table: 14 columns exceeds max 12`) so a producer — human or agent — +can repair the payload without a round-trip. + +The relay is the strict gate. Tolerance (below) lives only in clients, for +historical or foreign events signed by other implementations. + +## Updates (edit semantics) + +A live update is a `kind:40003` edit event (NIP-29 message edit) whose `e` tag +targets the surface and whose `content` is the **full replacement spec**. There +are no partial patches. + +- The edit's `content` MUST itself validate as a `SurfaceSpec` (relays enforce + this; see §Relay behavior). +- Only the surface's author — or, for an agent-authored surface, the agent's + owning human — may edit it. +- The edit's channel MUST match the target surface's channel. +- Latest edit wins. Because `created_at` has one-second resolution, clients MUST + order edits by `created_at`, breaking ties on the **lexicographically + smallest** event id, so two same-second edits resolve identically on every + client. + + Smallest-id is not arbitrary: a relay that orders results + `created_at DESC, id ASC` returns the winner as the first row, so a reader + can resolve current state with a one-row lookup per target instead of + fetching an unbounded edit history. The consequence is worth stating plainly: + a burst of edits inside a single second has no defined "last" — every reader + converges on the same one, but which one is arbitrary. Authors who need a + specific final state should let a second elapse. + +## Client behavior (tolerant) + +A rendering client parses `content` and applies this fallback matrix: + +| Condition | Render | +|---|---| +| valid v1 spec | native card | +| `version` ≠ 1 / unknown version | `fallbackText` as plain text | +| some nodes invalid | drop the invalid nodes, render the survivors | +| unknown `tone` | coerce to `default`, keep the node | +| numeric cell/value | render the number as text | +| zero valid nodes | `fallbackText` as plain text | +| JSON unparseable, or `fallbackText` missing/blank | escaped raw `content` as plain text | + +A client MUST NOT route any failure path through a markdown renderer, and MUST +NOT render a blank row or an error row. When a client can salvage a usable +`fallbackText` from an otherwise-unparseable envelope (e.g. a future spec +version whose node shapes it does not understand), it SHOULD prefer that plain +text over raw JSON. + +Clients that do not implement this NIP simply do not match the surface kind in +their timeline filters; the event is invisible to them. This is the intended +degradation. + +## Rationale + +**Why a Buzz-owned schema rather than an existing "generative UI" format.** +A signed event is durable protocol: it lives forever and must render the same in +a Rust relay, a React desktop client, and a future Flutter client. That argues +for a tiny, stable, trivially-reimplementable vocabulary, not a fast-moving +third-party runtime format. Generation-side token cost (the usual argument for +richer formats) is decoupled from the wire: an agent MAY generate in any format +and transpile to canonical `SurfaceSpec` before signing. + +**Why data-only.** The workspace's whole security model is "signed, inspectable, +no scripts." Layout/behavior control would hand that back to the author. Fixing +the vocabulary keeps every card auditable and consistent, and lets clients +enforce accessibility (tone is never color-alone) and zoom behavior uniformly. + +**Why edit-based updates.** Reusing `kind:40003` means a card and its whole +history live in one message row with one audit trail, instead of spawning a new +row per update that clients would have to de-duplicate.