@@ -9,7 +9,11 @@ use nostr::Event;
99use sprout_audit:: { AuditAction , NewAuditEntry } ;
1010use sprout_core:: event:: StoredEvent ;
1111use sprout_core:: kind:: {
12- event_kind_u32, is_ephemeral, is_workflow_execution_kind, KIND_AUTH , KIND_PRESENCE_UPDATE ,
12+ event_kind_u32, is_ephemeral, is_workflow_execution_kind, KIND_AUTH , KIND_CANVAS ,
13+ KIND_FORUM_COMMENT , KIND_FORUM_POST , KIND_FORUM_VOTE , KIND_PRESENCE_UPDATE ,
14+ KIND_STREAM_MESSAGE , KIND_STREAM_MESSAGE_BOOKMARKED , KIND_STREAM_MESSAGE_EDIT ,
15+ KIND_STREAM_MESSAGE_PINNED , KIND_STREAM_MESSAGE_SCHEDULED , KIND_STREAM_MESSAGE_V2 ,
16+ KIND_STREAM_REMINDER ,
1317} ;
1418use sprout_core:: verification:: verify_event;
1519
@@ -19,6 +23,75 @@ use crate::connection::{AuthState, ConnectionState};
1923use crate :: protocol:: RelayMessage ;
2024use crate :: state:: AppState ;
2125
26+ /// Publish a stored event to subscribers and kick off async side effects.
27+ pub ( crate ) async fn dispatch_persistent_event (
28+ state : & Arc < AppState > ,
29+ stored_event : & StoredEvent ,
30+ kind_u32 : u32 ,
31+ actor_pubkey_hex : & str ,
32+ ) -> usize {
33+ let event_id_hex = stored_event. event . id . to_hex ( ) ;
34+
35+ if let Some ( ch_id) = stored_event. channel_id {
36+ if let Err ( e) = state. pubsub . publish_event ( ch_id, & stored_event. event ) . await {
37+ warn ! ( event_id = %event_id_hex, "Redis publish failed: {e}" ) ;
38+ }
39+ }
40+
41+ let matches = state. sub_registry . fan_out ( stored_event) ;
42+ debug ! (
43+ event_id = %event_id_hex,
44+ channel_id = ?stored_event. channel_id,
45+ match_count = matches. len( ) ,
46+ "Fan-out"
47+ ) ;
48+
49+ let event_json = serde_json:: to_string ( & stored_event. event )
50+ . expect ( "nostr::Event serialization is infallible for well-formed events" ) ;
51+ for ( target_conn_id, sub_id) in & matches {
52+ let msg = format ! ( r#"["EVENT","{}",{}]"# , sub_id, event_json) ;
53+ state. conn_manager . send_to ( * target_conn_id, msg) ;
54+ }
55+
56+ let search = Arc :: clone ( & state. search ) ;
57+ let stored_for_search = stored_event. clone ( ) ;
58+ tokio:: spawn ( async move {
59+ if let Err ( e) = search. index_event ( & stored_for_search) . await {
60+ error ! ( event_id = %stored_for_search. event. id. to_hex( ) , "Search index failed: {e}" ) ;
61+ }
62+ } ) ;
63+
64+ let audit = Arc :: clone ( & state. audit ) ;
65+ let audit_event_id = event_id_hex. clone ( ) ;
66+ let audit_actor_pubkey = actor_pubkey_hex. to_string ( ) ;
67+ let audit_channel_id = stored_event. channel_id ;
68+ tokio:: spawn ( async move {
69+ let entry = NewAuditEntry {
70+ event_id : audit_event_id. clone ( ) ,
71+ event_kind : kind_u32,
72+ actor_pubkey : audit_actor_pubkey,
73+ action : AuditAction :: EventCreated ,
74+ channel_id : audit_channel_id,
75+ metadata : serde_json:: Value :: Null ,
76+ } ;
77+ if let Err ( e) = audit. log ( entry) . await {
78+ error ! ( event_id = %audit_event_id, "Audit log failed: {e}" ) ;
79+ }
80+ } ) ;
81+
82+ if !is_workflow_execution_kind ( kind_u32) {
83+ let workflow_engine = Arc :: clone ( & state. workflow_engine ) ;
84+ let workflow_event = stored_event. clone ( ) ;
85+ tokio:: spawn ( async move {
86+ if let Err ( e) = workflow_engine. on_event ( & workflow_event) . await {
87+ tracing:: error!( event_id = ?workflow_event. event. id, "Workflow trigger failed: {e}" ) ;
88+ }
89+ } ) ;
90+ }
91+
92+ matches. len ( )
93+ }
94+
2295/// Handle an EVENT message: authenticate, verify, store, fan-out, index, and audit the event.
2396pub async fn handle_event ( event : Event , conn : Arc < ConnectionState > , state : Arc < AppState > ) {
2497 let event_id_hex = event. id . to_hex ( ) ;
@@ -169,6 +242,15 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
169242 extract_channel_id ( & event)
170243 } ;
171244
245+ if requires_h_channel_scope ( kind_u32) && channel_id. is_none ( ) {
246+ conn. send ( RelayMessage :: ok (
247+ & event_id_hex,
248+ false ,
249+ "invalid: channel-scoped events must include an h tag" ,
250+ ) ) ;
251+ return ;
252+ }
253+
172254 if let Some ( ch_id) = channel_id {
173255 if let Err ( msg) =
174256 check_channel_membership ( & state, ch_id, & pubkey_bytes, conn_id, & event_id_hex) . await
@@ -248,70 +330,15 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
248330 }
249331 }
250332
251- if let Some ( ch_id) = channel_id {
252- if let Err ( e) = state. pubsub . publish_event ( ch_id, & event) . await {
253- warn ! ( event_id = %event_id_hex, "Redis publish failed: {e}" ) ;
254- }
255- }
256-
257- let matches = state. sub_registry . fan_out ( & stored_event) ;
258- debug ! (
259- event_id = %event_id_hex,
260- channel_id = ?stored_event. channel_id,
261- match_count = matches. len( ) ,
262- "Fan-out"
263- ) ;
264- let event_json = serde_json:: to_string ( & stored_event. event )
265- . expect ( "nostr::Event serialization is infallible for well-formed events" ) ;
266- for ( target_conn_id, sub_id) in & matches {
267- let msg = format ! ( r#"["EVENT","{}",{}]"# , sub_id, event_json) ;
268- state. conn_manager . send_to ( * target_conn_id, msg) ;
269- }
270-
271- let search = Arc :: clone ( & state. search ) ;
272- let stored_for_search = stored_event. clone ( ) ;
273- tokio:: spawn ( async move {
274- if let Err ( e) = search. index_event ( & stored_for_search) . await {
275- error ! ( event_id = %stored_for_search. event. id. to_hex( ) , "Search index failed: {e}" ) ;
276- }
277- } ) ;
278-
279- let audit = Arc :: clone ( & state. audit ) ;
280- let audit_event_id = event_id_hex. clone ( ) ;
281- let audit_pubkey = pubkey_hex. clone ( ) ;
282- tokio:: spawn ( async move {
283- let entry = NewAuditEntry {
284- event_id : audit_event_id. clone ( ) ,
285- event_kind : kind_u32,
286- actor_pubkey : audit_pubkey,
287- action : AuditAction :: EventCreated ,
288- channel_id,
289- metadata : serde_json:: Value :: Null ,
290- } ;
291- if let Err ( e) = audit. log ( entry) . await {
292- error ! ( event_id = %audit_event_id, "Audit log failed: {e}" ) ;
293- }
294- } ) ;
295-
296- // Don't trigger workflows for workflow execution events (prevents infinite loops).
297- let is_workflow_event = is_workflow_execution_kind ( kind_u32) ;
298- if !is_workflow_event {
299- let wf = Arc :: clone ( & state. workflow_engine ) ;
300- let ev = stored_event. clone ( ) ;
301- tokio:: spawn ( async move {
302- if let Err ( e) = wf. on_event ( & ev) . await {
303- tracing:: error!( event_id = ?ev. event. id, "Workflow trigger failed: {e}" ) ;
304- }
305- } ) ;
306- }
333+ let fan_out = dispatch_persistent_event ( & state, & stored_event, kind_u32, & pubkey_hex) . await ;
307334
308335 conn. send ( RelayMessage :: ok ( & event_id_hex, true , "" ) ) ;
309336
310337 info ! (
311338 event_id = %event_id_hex,
312339 kind = kind_u32,
313340 conn_id = %conn_id,
314- fan_out = matches . len ( ) ,
341+ fan_out,
315342 "Event ingested"
316343 ) ;
317344}
@@ -538,12 +565,12 @@ async fn derive_reaction_channel(
538565
539566/// Extract a channel UUID from event tags.
540567///
541- /// Checks `"channel"` custom tags and `"h"` NIP-29 group tags for a channel UUID.
568+ /// Checks the `"h"` NIP-29 group tag for a channel UUID.
542569/// The `"e"` tag is intentionally NOT checked — it is reserved for event references only.
543570fn extract_channel_id ( event : & Event ) -> Option < uuid:: Uuid > {
544571 for tag in event. tags . iter ( ) {
545572 let key = tag. kind ( ) . to_string ( ) ;
546- if key == "channel" || key == " h" {
573+ if key == "h" {
547574 if let Some ( val) = tag. content ( ) {
548575 if let Ok ( id) = val. parse :: < uuid:: Uuid > ( ) {
549576 return Some ( id) ;
@@ -553,3 +580,57 @@ fn extract_channel_id(event: &Event) -> Option<uuid::Uuid> {
553580 }
554581 None
555582}
583+
584+ fn requires_h_channel_scope ( kind : u32 ) -> bool {
585+ matches ! (
586+ kind,
587+ KIND_STREAM_MESSAGE
588+ | KIND_STREAM_MESSAGE_V2
589+ | KIND_STREAM_MESSAGE_EDIT
590+ | KIND_STREAM_MESSAGE_PINNED
591+ | KIND_STREAM_MESSAGE_BOOKMARKED
592+ | KIND_STREAM_MESSAGE_SCHEDULED
593+ | KIND_STREAM_REMINDER
594+ | KIND_CANVAS
595+ | KIND_FORUM_POST
596+ | KIND_FORUM_VOTE
597+ | KIND_FORUM_COMMENT
598+ )
599+ }
600+
601+ #[ cfg( test) ]
602+ mod tests {
603+ use super :: requires_h_channel_scope;
604+ use sprout_core:: kind:: {
605+ KIND_CANVAS , KIND_FORUM_COMMENT , KIND_FORUM_POST , KIND_FORUM_VOTE , KIND_PRESENCE_UPDATE ,
606+ KIND_STREAM_MESSAGE ,
607+ } ;
608+
609+ #[ test]
610+ fn channel_scoped_content_kinds_require_h_tags ( ) {
611+ for kind in [
612+ KIND_STREAM_MESSAGE ,
613+ KIND_CANVAS ,
614+ KIND_FORUM_POST ,
615+ KIND_FORUM_VOTE ,
616+ KIND_FORUM_COMMENT ,
617+ ] {
618+ assert ! (
619+ requires_h_channel_scope( kind) ,
620+ "kind {kind} should require h"
621+ ) ;
622+ }
623+ }
624+
625+ #[ test]
626+ fn non_channel_kinds_do_not_require_h_tags ( ) {
627+ assert ! (
628+ !requires_h_channel_scope( nostr:: Kind :: Reaction . as_u16( ) . into( ) ) ,
629+ "reactions derive channel from the target event"
630+ ) ;
631+ assert ! (
632+ !requires_h_channel_scope( KIND_PRESENCE_UPDATE ) ,
633+ "presence updates are global/ephemeral"
634+ ) ;
635+ }
636+ }
0 commit comments