From 299aa3b5fdb2c05c7f8ca8fd5d7eb79535e8302e Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 10 Mar 2026 15:08:42 -0700 Subject: [PATCH 1/2] Migrate channel tags to h and fix REST fan-out --- crates/sprout-mcp/src/server.rs | 29 ++-- crates/sprout-relay/src/api/messages.rs | 13 +- crates/sprout-relay/src/handlers/event.rs | 132 ++++++++++-------- crates/sprout-relay/src/handlers/req.rs | 6 +- .../sprout-relay/src/handlers/side_effects.rs | 2 +- crates/sprout-test-client/src/lib.rs | 14 +- crates/sprout-test-client/tests/e2e_relay.rs | 14 +- .../sprout-test-client/tests/e2e_rest_api.rs | 81 ++++++++++- desktop/src/app/AppShell.tsx | 2 +- desktop/src/features/messages/hooks.ts | 5 +- desktop/src/shared/api/relayClient.ts | 13 +- desktop/src/testing/e2eBridge.ts | 8 +- 12 files changed, 199 insertions(+), 120 deletions(-) diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 398ed7d..d24ca71 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -484,8 +484,8 @@ impl SproutMcpServer { const MAX_HISTORY_LIMIT: u32 = 200; let limit = p.limit.unwrap_or(50).min(MAX_HISTORY_LIMIT); - // Always use the REST endpoint — the channel tag is multi-character ("channel") - // and cannot be filtered via WebSocket subscription SingleLetterTag filters. + // Use the REST endpoint so callers get the canonical history payload, + // including thread metadata when requested. let with_threads = p.with_threads.unwrap_or(false); let path = if with_threads { format!( @@ -546,11 +546,12 @@ impl SproutMcpServer { return format!("Error: {e}"); } - // The "channel" tag is multi-character and cannot be used in WebSocket - // subscription filters (nostr::Filter::custom_tag only accepts SingleLetterTag). - // Subscribe to all KIND_CANVAS events and filter client-side by channel tag. let filter = nostr::Filter::new() .kind(nostr::Kind::Custom(KIND_CANVAS as u16)) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::H), + [p.channel_id.as_str()], + ) .limit(50); let sub_id = format!("canvas-{}", uuid::Uuid::new_v4()); @@ -560,15 +561,7 @@ impl SproutMcpServer { }; let _ = self.client.close_subscription(&sub_id).await; - // Filter client-side: find the most recent canvas event for this channel. - let canvas_event = events.iter().rev().find(|event| { - event - .tags - .find(nostr::TagKind::custom("channel")) - .and_then(|t| t.content()) - .map(|v| v == p.channel_id.as_str()) - .unwrap_or(false) - }); + let canvas_event = events.iter().max_by_key(|event| event.created_at.as_u64()); if let Some(event) = canvas_event { event.content.clone() @@ -589,19 +582,15 @@ impl SproutMcpServer { let keys = self.client.keys().clone(); - let channel_tag = match nostr::Tag::parse(&["channel", &p.channel_id]) { + let channel_tag = match nostr::Tag::parse(&["h", &p.channel_id]) { Ok(t) => t, Err(e) => return format!("Error building tag: {e}"), }; - let event_ref_tag = match nostr::Tag::parse(&["e", &p.channel_id]) { - Ok(t) => t, - Err(e) => return format!("Error building event-ref tag: {e}"), - }; let event = match nostr::EventBuilder::new( nostr::Kind::Custom(KIND_CANVAS as u16), &p.content, - [channel_tag, event_ref_tag], + [channel_tag], ) .sign_with_keys(&keys) { diff --git a/crates/sprout-relay/src/api/messages.rs b/crates/sprout-relay/src/api/messages.rs index d758dd5..8719210 100644 --- a/crates/sprout-relay/src/api/messages.rs +++ b/crates/sprout-relay/src/api/messages.rs @@ -27,6 +27,7 @@ use nostr::util::hex as nostr_hex; use nostr::{EventBuilder, Kind, Tag}; use serde::Deserialize; +use crate::handlers::event::dispatch_persistent_event; use crate::state::AppState; use super::{ @@ -237,8 +238,9 @@ pub async fn send_message( // Attribution to the actual sender. Tag::parse(&["p", &user_pubkey_hex]) .map_err(|e| internal_error(&format!("tag build error: {e}")))?, - // Channel tag so Nostr clients can find this event by channel. - Tag::custom(nostr::TagKind::custom("channel"), [channel_id.to_string()]), + // Channel-scoped messages use the NIP-29 `h` tag. + Tag::parse(&["h", &channel_id.to_string()]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, ]; // Thread reply tags (NIP-10 style). @@ -297,12 +299,17 @@ pub async fn send_message( broadcast: body.broadcast_to_channel, }); - state + let (stored_event, was_inserted) = state .db .insert_event_with_thread_metadata(&event, Some(channel_id), thread_meta) .await .map_err(|e| internal_error(&format!("db error: {e}")))?; + if was_inserted { + let kind_u32 = u32::from(event.kind.as_u16()); + let _ = dispatch_persistent_event(&state, &stored_event, kind_u32, &user_pubkey_hex).await; + } + // ── Response ────────────────────────────────────────────────────────────── Ok(Json(serde_json::json!({ diff --git a/crates/sprout-relay/src/handlers/event.rs b/crates/sprout-relay/src/handlers/event.rs index 311084f..6715980 100644 --- a/crates/sprout-relay/src/handlers/event.rs +++ b/crates/sprout-relay/src/handlers/event.rs @@ -19,6 +19,75 @@ use crate::connection::{AuthState, ConnectionState}; use crate::protocol::RelayMessage; use crate::state::AppState; +/// Publish a stored event to subscribers and kick off async side effects. +pub(crate) async fn dispatch_persistent_event( + state: &Arc, + stored_event: &StoredEvent, + kind_u32: u32, + actor_pubkey_hex: &str, +) -> usize { + let event_id_hex = stored_event.event.id.to_hex(); + + if let Some(ch_id) = stored_event.channel_id { + if let Err(e) = state.pubsub.publish_event(ch_id, &stored_event.event).await { + warn!(event_id = %event_id_hex, "Redis publish failed: {e}"); + } + } + + let matches = state.sub_registry.fan_out(stored_event); + debug!( + event_id = %event_id_hex, + channel_id = ?stored_event.channel_id, + match_count = matches.len(), + "Fan-out" + ); + + let event_json = serde_json::to_string(&stored_event.event) + .expect("nostr::Event serialization is infallible for well-formed events"); + for (target_conn_id, sub_id) in &matches { + let msg = format!(r#"["EVENT","{}",{}]"#, sub_id, event_json); + state.conn_manager.send_to(*target_conn_id, msg); + } + + let search = Arc::clone(&state.search); + let stored_for_search = stored_event.clone(); + tokio::spawn(async move { + if let Err(e) = search.index_event(&stored_for_search).await { + error!(event_id = %stored_for_search.event.id.to_hex(), "Search index failed: {e}"); + } + }); + + let audit = Arc::clone(&state.audit); + let audit_event_id = event_id_hex.clone(); + let audit_actor_pubkey = actor_pubkey_hex.to_string(); + let audit_channel_id = stored_event.channel_id; + tokio::spawn(async move { + let entry = NewAuditEntry { + event_id: audit_event_id.clone(), + event_kind: kind_u32, + actor_pubkey: audit_actor_pubkey, + action: AuditAction::EventCreated, + channel_id: audit_channel_id, + metadata: serde_json::Value::Null, + }; + if let Err(e) = audit.log(entry).await { + error!(event_id = %audit_event_id, "Audit log failed: {e}"); + } + }); + + if !is_workflow_execution_kind(kind_u32) { + let workflow_engine = Arc::clone(&state.workflow_engine); + let workflow_event = stored_event.clone(); + tokio::spawn(async move { + if let Err(e) = workflow_engine.on_event(&workflow_event).await { + tracing::error!(event_id = ?workflow_event.event.id, "Workflow trigger failed: {e}"); + } + }); + } + + matches.len() +} + /// Handle an EVENT message: authenticate, verify, store, fan-out, index, and audit the event. pub async fn handle_event(event: Event, conn: Arc, state: Arc) { let event_id_hex = event.id.to_hex(); @@ -248,62 +317,7 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc, state: Arc Option { for tag in event.tags.iter() { let key = tag.kind().to_string(); - if key == "channel" || key == "h" { + if key == "h" { if let Some(val) = tag.content() { if let Ok(id) = val.parse::() { return Some(id); diff --git a/crates/sprout-relay/src/handlers/req.rs b/crates/sprout-relay/src/handlers/req.rs index 9933532..d474ae5 100644 --- a/crates/sprout-relay/src/handlers/req.rs +++ b/crates/sprout-relay/src/handlers/req.rs @@ -195,7 +195,7 @@ fn filter_to_query_params(filter: &Filter, channel_id: Option) -> Ev /// Extract a single channel UUID from filter generic tags, or `None` if the /// subscription is logically global. /// -/// Checks both `"channel"` and `"e"` tag keys — clients use `#e` with a UUID value. +/// Checks the `"h"` tag key — channel-scoped subscriptions use `#h = `. /// /// Returns `None` when: /// - Any filter has no channel tag (that filter matches all channels → global sub), or @@ -208,7 +208,7 @@ fn extract_channel_id_from_filters(filters: &[Filter]) -> Option { let mut filter_has_channel = false; for (tag_key, tag_values) in f.generic_tags.iter() { let key = tag_key.to_string(); - if key == "channel" || key == "e" { + if key == "h" { for val in tag_values { if let Ok(id) = val.parse::() { filter_has_channel = true; @@ -238,7 +238,7 @@ mod tests { fn filter_with_channel(channel_id: uuid::Uuid) -> Filter { Filter::new().custom_tag( - SingleLetterTag::lowercase(Alphabet::E), + SingleLetterTag::lowercase(Alphabet::H), [channel_id.to_string()], ) } diff --git a/crates/sprout-relay/src/handlers/side_effects.rs b/crates/sprout-relay/src/handlers/side_effects.rs index 0413e10..afe07c0 100644 --- a/crates/sprout-relay/src/handlers/side_effects.rs +++ b/crates/sprout-relay/src/handlers/side_effects.rs @@ -233,7 +233,7 @@ pub async fn emit_system_message( channel_id: Uuid, content: serde_json::Value, ) -> anyhow::Result<()> { - let channel_tag = Tag::custom(nostr::TagKind::custom("channel"), [channel_id.to_string()]); + let channel_tag = Tag::parse(&["h", &channel_id.to_string()])?; let event = EventBuilder::new(Kind::Custom(40099), content.to_string(), [channel_tag]) .sign_with_keys(&state.relay_keypair) diff --git a/crates/sprout-test-client/src/lib.rs b/crates/sprout-test-client/src/lib.rs index 19fe4bc..069e226 100644 --- a/crates/sprout-test-client/src/lib.rs +++ b/crates/sprout-test-client/src/lib.rs @@ -159,9 +159,9 @@ impl SproutTestClient { content: &str, kind: u16, ) -> Result { - let e_tag = Tag::parse(&["e", channel_id]) + let h_tag = Tag::parse(&["h", channel_id]) .map_err(|e| TestClientError::EventBuilder(e.to_string()))?; - let event = EventBuilder::new(Kind::Custom(kind), content, [e_tag]).sign_with_keys(keys)?; + let event = EventBuilder::new(Kind::Custom(kind), content, [h_tag]).sign_with_keys(keys)?; self.send_event(event).await } @@ -534,11 +534,11 @@ mod tests { } #[test] - fn text_event_carries_e_tag() { + fn text_event_carries_h_tag() { let keys = Keys::generate(); let channel_id = "my-channel-123"; - let e_tag = Tag::parse(&["e", channel_id]).unwrap(); - let event = EventBuilder::new(Kind::Custom(40001), "hello", [e_tag]) + let h_tag = Tag::parse(&["h", channel_id]).unwrap(); + let event = EventBuilder::new(Kind::Custom(40001), "hello", [h_tag]) .sign_with_keys(&keys) .unwrap(); @@ -551,8 +551,8 @@ mod tests { assert!( tags.iter() - .any(|t| t.len() >= 2 && t[0] == "e" && t[1] == channel_id), - "missing e tag" + .any(|t| t.len() >= 2 && t[0] == "h" && t[1] == channel_id), + "missing h tag" ); } } diff --git a/crates/sprout-test-client/tests/e2e_relay.rs b/crates/sprout-test-client/tests/e2e_relay.rs index de73e0a..ef6e90d 100644 --- a/crates/sprout-test-client/tests/e2e_relay.rs +++ b/crates/sprout-test-client/tests/e2e_relay.rs @@ -65,7 +65,7 @@ async fn test_send_event_and_receive_via_subscription() { let sid = sub_id("send-recv"); let filter = Filter::new() .kind(Kind::Custom(kind)) - .custom_tag(SingleLetterTag::lowercase(Alphabet::E), [channel.as_str()]); + .custom_tag(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]); client_a .subscribe(&sid, vec![filter]) @@ -124,7 +124,7 @@ async fn test_subscription_filters_by_kind() { let sid = sub_id("filter-kind"); let filter = Filter::new() .kind(Kind::Custom(target_kind)) - .custom_tag(SingleLetterTag::lowercase(Alphabet::E), [channel.as_str()]); + .custom_tag(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]); client .subscribe(&sid, vec![filter]) @@ -194,7 +194,7 @@ async fn test_close_subscription_stops_delivery() { let sid = sub_id("close-sub"); let filter = Filter::new() .kind(Kind::Custom(kind)) - .custom_tag(SingleLetterTag::lowercase(Alphabet::E), [channel.as_str()]); + .custom_tag(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]); client .subscribe(&sid, vec![filter]) @@ -287,7 +287,7 @@ async fn test_multiple_concurrent_clients() { let filter = Filter::new() .kind(Kind::Custom(kind)) - .custom_tag(SingleLetterTag::lowercase(Alphabet::E), [channel.as_str()]); + .custom_tag(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]); for (i, client) in clients.iter_mut().enumerate() { let sid = format!("multi-{i}"); @@ -350,7 +350,7 @@ async fn test_stored_events_returned_before_eose() { let sid = sub_id("stored"); let filter = Filter::new() .kind(Kind::Custom(kind)) - .custom_tag(SingleLetterTag::lowercase(Alphabet::E), [channel.as_str()]); + .custom_tag(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]); client .subscribe(&sid, vec![filter]) @@ -397,7 +397,7 @@ async fn test_ephemeral_event_not_stored() { let sid = sub_id("ephemeral"); let filter = Filter::new() .kind(Kind::Custom(ephemeral_kind)) - .custom_tag(SingleLetterTag::lowercase(Alphabet::E), [channel.as_str()]); + .custom_tag(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]); client .subscribe(&sid, vec![filter]) @@ -603,7 +603,7 @@ async fn test_eose_sent_for_empty_subscription() { let sid = sub_id("empty-eose"); let filter = Filter::new() .kind(Kind::Custom(kind)) - .custom_tag(SingleLetterTag::lowercase(Alphabet::E), [channel.as_str()]) + .custom_tag(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]) .since(nostr::Timestamp::now()); client diff --git a/crates/sprout-test-client/tests/e2e_rest_api.rs b/crates/sprout-test-client/tests/e2e_rest_api.rs index e401edb..8d99815 100644 --- a/crates/sprout-test-client/tests/e2e_rest_api.rs +++ b/crates/sprout-test-client/tests/e2e_rest_api.rs @@ -27,9 +27,9 @@ use std::time::Duration; -use nostr::{Keys, Kind, Tag}; +use nostr::{Alphabet, Filter, Keys, Kind, SingleLetterTag, Tag}; use reqwest::Client; -use sprout_test_client::SproutTestClient; +use sprout_test_client::{RelayMessage, SproutTestClient}; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -225,6 +225,83 @@ async fn test_channel_visibility_open_channels_visible_to_all() { ); } +/// REST-created channel messages must fan out to WebSocket subscribers and +/// carry the canonical channel `h` tag. +#[tokio::test] +#[ignore] +async fn test_rest_send_message_reaches_websocket_channel_subscriptions() { + let client = http_client(); + let subscriber_keys = Keys::generate(); + let poster_keys = Keys::generate(); + let ws_url = relay_ws_url(); + + let mut subscriber = SproutTestClient::connect(&ws_url, &subscriber_keys) + .await + .expect("WebSocket connect failed"); + + let sid = format!("rest-live-{}", uuid::Uuid::new_v4().simple()); + let filter = Filter::new() + .kind(Kind::Custom(40001)) + .custom_tag(SingleLetterTag::lowercase(Alphabet::H), [CHANNEL_GENERAL]); + + subscriber + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe failed"); + subscriber + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("EOSE failed"); + + let content = format!("E2E REST live message: {}", uuid::Uuid::new_v4().simple()); + let url = format!( + "{}/api/channels/{}/messages", + relay_http_url(), + CHANNEL_GENERAL + ); + let resp = authed_post_json( + &client, + &url, + &poster_keys.public_key().to_hex(), + serde_json::json!({ "content": content }), + ) + .await; + + assert_eq!(resp.status(), 200, "expected 200 OK from REST send_message"); + + let message = subscriber + .recv_event(Duration::from_secs(5)) + .await + .expect("subscriber did not receive live event"); + + match message { + RelayMessage::Event { event, .. } => { + assert_eq!(event.content, content); + + let tags: Vec> = event + .tags + .iter() + .map(|tag| tag.as_slice().iter().map(|part| part.to_string()).collect()) + .collect(); + + assert!( + tags.iter() + .any(|tag| tag.len() >= 2 && tag[0] == "h" && tag[1] == CHANNEL_GENERAL), + "REST-created message is missing the channel h tag: {tags:?}" + ); + assert!( + tags.iter().any(|tag| { + tag.len() >= 2 && tag[0] == "p" && tag[1] == poster_keys.public_key().to_hex() + }), + "REST-created message is missing the sender attribution p tag: {tags:?}" + ); + } + other => panic!("expected live EVENT after REST send_message, got {other:?}"), + } + + subscriber.disconnect().await.expect("disconnect failed"); +} + /// GET /api/channels requires authentication — unauthenticated requests are rejected. #[tokio::test] #[ignore] diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 4fef419..d452c86 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -32,7 +32,7 @@ function createSearchAnchorEvent(hit: SearchHit): RelayEvent { pubkey: hit.pubkey, created_at: hit.createdAt, kind: hit.kind, - tags: [["e", hit.channelId]], + tags: [["h", hit.channelId]], content: hit.content, sig: "", }; diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 45a56f2..4d343df 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -35,10 +35,7 @@ function createOptimisticMessage( pubkey: identity.pubkey, created_at: Math.floor(Date.now() / 1_000), kind: 4_0001, - tags: [ - ["channel", channelId], - ["e", channelId], - ], + tags: [["h", channelId]], content, sig: "", pending: true, diff --git a/desktop/src/shared/api/relayClient.ts b/desktop/src/shared/api/relayClient.ts index 5381e7d..182793e 100644 --- a/desktop/src/shared/api/relayClient.ts +++ b/desktop/src/shared/api/relayClient.ts @@ -9,7 +9,7 @@ import type { RelayEvent } from "@/shared/api/types"; type RelaySubscriptionFilter = { kinds: number[]; - "#e": string[]; + "#h": string[]; limit: number; }; @@ -122,13 +122,8 @@ class RelayClient { const event = await signRelayEvent({ kind: 40001, content: content.trim(), - // Include both tags for now: - // - `channel` lets the relay persist and authorize the event as channel-scoped - // - `e` keeps the existing desktop WebSocket history/subscription filters working - tags: [ - ["channel", channelId], - ["e", channelId], - ], + // Channel-scoped events use the NIP-29 `h` tag. + tags: [["h", channelId]], }); return new Promise((resolve, reject) => { @@ -249,7 +244,7 @@ class RelayClient { ): RelaySubscriptionFilter { return { kinds: [40001], - "#e": [channelId], + "#h": [channelId], limit, }; } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 455bae4..70303ee 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -230,7 +230,7 @@ function sendWsClose(handler: WsHandler) { } function getChannelIdFromTags(tags: string[][]): string | undefined { - return tags.find((tag) => tag[0] === "e")?.[1]; + return tags.find((tag) => tag[0] === "h")?.[1]; } function getMockMessageStore(channelId: string): RelayEvent[] { @@ -247,7 +247,7 @@ function getMockMessageStore(channelId: string): RelayEvent[] { pubkey: DEFAULT_MOCK_IDENTITY.pubkey, created_at: Math.floor(Date.now() / 1000), kind: 40001, - tags: [["e", channelId]], + tags: [["h", channelId]], content: "Welcome to #general", sig: "mocksig".repeat(20).slice(0, 128), }, @@ -744,8 +744,8 @@ function sendToMockSocket(args: { if (type === "REQ") { const subId = rest[0] as string; - const filter = rest[1] as { "#e"?: string[] }; - const channelId = filter["#e"]?.[0]; + const filter = rest[1] as { "#h"?: string[] }; + const channelId = filter["#h"]?.[0]; if (!channelId) { sendWsText(socket.handler, ["EOSE", subId]); return; From 193223f039a471f74eb6c0387f090847ca13d56c Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 10 Mar 2026 15:20:48 -0700 Subject: [PATCH 2/2] Reject channel content events without h tags --- crates/sprout-relay/src/handlers/event.rs | 69 ++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/sprout-relay/src/handlers/event.rs b/crates/sprout-relay/src/handlers/event.rs index 6715980..7f2dc06 100644 --- a/crates/sprout-relay/src/handlers/event.rs +++ b/crates/sprout-relay/src/handlers/event.rs @@ -9,7 +9,11 @@ use nostr::Event; use sprout_audit::{AuditAction, NewAuditEntry}; use sprout_core::event::StoredEvent; use sprout_core::kind::{ - event_kind_u32, is_ephemeral, is_workflow_execution_kind, KIND_AUTH, KIND_PRESENCE_UPDATE, + event_kind_u32, is_ephemeral, is_workflow_execution_kind, KIND_AUTH, KIND_CANVAS, + KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE, + KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_EDIT, + KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, + KIND_STREAM_REMINDER, }; use sprout_core::verification::verify_event; @@ -238,6 +242,15 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc Option { } None } + +fn requires_h_channel_scope(kind: u32) -> bool { + matches!( + kind, + KIND_STREAM_MESSAGE + | KIND_STREAM_MESSAGE_V2 + | KIND_STREAM_MESSAGE_EDIT + | KIND_STREAM_MESSAGE_PINNED + | KIND_STREAM_MESSAGE_BOOKMARKED + | KIND_STREAM_MESSAGE_SCHEDULED + | KIND_STREAM_REMINDER + | KIND_CANVAS + | KIND_FORUM_POST + | KIND_FORUM_VOTE + | KIND_FORUM_COMMENT + ) +} + +#[cfg(test)] +mod tests { + use super::requires_h_channel_scope; + use sprout_core::kind::{ + KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE, + KIND_STREAM_MESSAGE, + }; + + #[test] + fn channel_scoped_content_kinds_require_h_tags() { + for kind in [ + KIND_STREAM_MESSAGE, + KIND_CANVAS, + KIND_FORUM_POST, + KIND_FORUM_VOTE, + KIND_FORUM_COMMENT, + ] { + assert!( + requires_h_channel_scope(kind), + "kind {kind} should require h" + ); + } + } + + #[test] + fn non_channel_kinds_do_not_require_h_tags() { + assert!( + !requires_h_channel_scope(nostr::Kind::Reaction.as_u16().into()), + "reactions derive channel from the target event" + ); + assert!( + !requires_h_channel_scope(KIND_PRESENCE_UPDATE), + "presence updates are global/ephemeral" + ); + } +}