Skip to content

Commit 9793c99

Browse files
authored
Migrate channel scoping to h tags (#22)
1 parent 3fb4699 commit 9793c99

12 files changed

Lines changed: 267 additions & 121 deletions

File tree

crates/sprout-mcp/src/server.rs

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -484,8 +484,8 @@ impl SproutMcpServer {
484484
const MAX_HISTORY_LIMIT: u32 = 200;
485485
let limit = p.limit.unwrap_or(50).min(MAX_HISTORY_LIMIT);
486486

487-
// Always use the REST endpoint — the channel tag is multi-character ("channel")
488-
// and cannot be filtered via WebSocket subscription SingleLetterTag filters.
487+
// Use the REST endpoint so callers get the canonical history payload,
488+
// including thread metadata when requested.
489489
let with_threads = p.with_threads.unwrap_or(false);
490490
let path = if with_threads {
491491
format!(
@@ -546,11 +546,12 @@ impl SproutMcpServer {
546546
return format!("Error: {e}");
547547
}
548548

549-
// The "channel" tag is multi-character and cannot be used in WebSocket
550-
// subscription filters (nostr::Filter::custom_tag only accepts SingleLetterTag).
551-
// Subscribe to all KIND_CANVAS events and filter client-side by channel tag.
552549
let filter = nostr::Filter::new()
553550
.kind(nostr::Kind::Custom(KIND_CANVAS as u16))
551+
.custom_tag(
552+
nostr::SingleLetterTag::lowercase(nostr::Alphabet::H),
553+
[p.channel_id.as_str()],
554+
)
554555
.limit(50);
555556

556557
let sub_id = format!("canvas-{}", uuid::Uuid::new_v4());
@@ -560,15 +561,7 @@ impl SproutMcpServer {
560561
};
561562
let _ = self.client.close_subscription(&sub_id).await;
562563

563-
// Filter client-side: find the most recent canvas event for this channel.
564-
let canvas_event = events.iter().rev().find(|event| {
565-
event
566-
.tags
567-
.find(nostr::TagKind::custom("channel"))
568-
.and_then(|t| t.content())
569-
.map(|v| v == p.channel_id.as_str())
570-
.unwrap_or(false)
571-
});
564+
let canvas_event = events.iter().max_by_key(|event| event.created_at.as_u64());
572565

573566
if let Some(event) = canvas_event {
574567
event.content.clone()
@@ -589,19 +582,15 @@ impl SproutMcpServer {
589582

590583
let keys = self.client.keys().clone();
591584

592-
let channel_tag = match nostr::Tag::parse(&["channel", &p.channel_id]) {
585+
let channel_tag = match nostr::Tag::parse(&["h", &p.channel_id]) {
593586
Ok(t) => t,
594587
Err(e) => return format!("Error building tag: {e}"),
595588
};
596-
let event_ref_tag = match nostr::Tag::parse(&["e", &p.channel_id]) {
597-
Ok(t) => t,
598-
Err(e) => return format!("Error building event-ref tag: {e}"),
599-
};
600589

601590
let event = match nostr::EventBuilder::new(
602591
nostr::Kind::Custom(KIND_CANVAS as u16),
603592
&p.content,
604-
[channel_tag, event_ref_tag],
593+
[channel_tag],
605594
)
606595
.sign_with_keys(&keys)
607596
{

crates/sprout-relay/src/api/messages.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ use nostr::util::hex as nostr_hex;
2727
use nostr::{EventBuilder, Kind, Tag};
2828
use serde::Deserialize;
2929

30+
use crate::handlers::event::dispatch_persistent_event;
3031
use crate::state::AppState;
3132

3233
use super::{
@@ -237,8 +238,9 @@ pub async fn send_message(
237238
// Attribution to the actual sender.
238239
Tag::parse(&["p", &user_pubkey_hex])
239240
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
240-
// Channel tag so Nostr clients can find this event by channel.
241-
Tag::custom(nostr::TagKind::custom("channel"), [channel_id.to_string()]),
241+
// Channel-scoped messages use the NIP-29 `h` tag.
242+
Tag::parse(&["h", &channel_id.to_string()])
243+
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
242244
];
243245

244246
// Thread reply tags (NIP-10 style).
@@ -297,12 +299,17 @@ pub async fn send_message(
297299
broadcast: body.broadcast_to_channel,
298300
});
299301

300-
state
302+
let (stored_event, was_inserted) = state
301303
.db
302304
.insert_event_with_thread_metadata(&event, Some(channel_id), thread_meta)
303305
.await
304306
.map_err(|e| internal_error(&format!("db error: {e}")))?;
305307

308+
if was_inserted {
309+
let kind_u32 = u32::from(event.kind.as_u16());
310+
let _ = dispatch_persistent_event(&state, &stored_event, kind_u32, &user_pubkey_hex).await;
311+
}
312+
306313
// ── Response ──────────────────────────────────────────────────────────────
307314

308315
Ok(Json(serde_json::json!({

crates/sprout-relay/src/handlers/event.rs

Lines changed: 141 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ use nostr::Event;
99
use sprout_audit::{AuditAction, NewAuditEntry};
1010
use sprout_core::event::StoredEvent;
1111
use 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
};
1418
use sprout_core::verification::verify_event;
1519

@@ -19,6 +23,75 @@ use crate::connection::{AuthState, ConnectionState};
1923
use crate::protocol::RelayMessage;
2024
use 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.
2396
pub 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.
543570
fn 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+
}

crates/sprout-relay/src/handlers/req.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ fn filter_to_query_params(filter: &Filter, channel_id: Option<uuid::Uuid>) -> Ev
195195
/// Extract a single channel UUID from filter generic tags, or `None` if the
196196
/// subscription is logically global.
197197
///
198-
/// Checks both `"channel"` and `"e"` tag keysclients use `#e` with a UUID value.
198+
/// Checks the `"h"` tag keychannel-scoped subscriptions use `#h = <uuid>`.
199199
///
200200
/// Returns `None` when:
201201
/// - 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<uuid::Uuid> {
208208
let mut filter_has_channel = false;
209209
for (tag_key, tag_values) in f.generic_tags.iter() {
210210
let key = tag_key.to_string();
211-
if key == "channel" || key == "e" {
211+
if key == "h" {
212212
for val in tag_values {
213213
if let Ok(id) = val.parse::<uuid::Uuid>() {
214214
filter_has_channel = true;
@@ -238,7 +238,7 @@ mod tests {
238238

239239
fn filter_with_channel(channel_id: uuid::Uuid) -> Filter {
240240
Filter::new().custom_tag(
241-
SingleLetterTag::lowercase(Alphabet::E),
241+
SingleLetterTag::lowercase(Alphabet::H),
242242
[channel_id.to_string()],
243243
)
244244
}

crates/sprout-relay/src/handlers/side_effects.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ pub async fn emit_system_message(
233233
channel_id: Uuid,
234234
content: serde_json::Value,
235235
) -> anyhow::Result<()> {
236-
let channel_tag = Tag::custom(nostr::TagKind::custom("channel"), [channel_id.to_string()]);
236+
let channel_tag = Tag::parse(&["h", &channel_id.to_string()])?;
237237

238238
let event = EventBuilder::new(Kind::Custom(40099), content.to_string(), [channel_tag])
239239
.sign_with_keys(&state.relay_keypair)

0 commit comments

Comments
 (0)