feat(relay): tag system-channel membership notices and hide them by default

A system channel now sends the usual stored kind:44100/44101 notice with
["t","system"]. The relay returns a tagged notice only to a filter whose
#t has "system". Existing clients never send that, so they see no change.

The channel type is checked once, where the notice is signed. Every read
then hides a tagged notice by default, in two places:

- filters_match (buzz-core), which live fan-out and every historical
  post-filter use;
- EventQuery (buzz-db), whose SQL excludes tagged notices unless
  include_system_notices is set, so COUNT and page limits stay exact.

A read path that does not opt in therefore behaves as it does today.

Because the notices are stored, a client can replay missed ones with
`since` instead of rereading every channel on reconnect.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-10-19 13:37:09 -04:00
parent af7979e1ca
commit 9b870663ef
7 changed files with 421 additions and 46 deletions

View File

@@ -152,7 +152,9 @@ out unless they ask for it:
combine with OR, so `#t:["system", <label>]` does not mean "a system
channel with this label".
Push and kind:44100/44101 membership notifications skip system channels.
Push notifications skip system channels. Their kind:44100/44101
membership notifications carry `["t","system"]`, and the relay returns those
only to a filter with `#t:["system"]` (see NIP-CL).
Events are stored **channel-scoped** so access control applies — private channel member lists are
only visible to members. Discover groups via historical REQ:
@@ -178,6 +180,9 @@ The relay emits relay-signed notifications when members are added or removed:
| **44100** | Member added | `p` = target pubkey, `h` = channel UUID | Community-global |
| **44101** | Member removed | `p` = target pubkey, `h` = channel UUID | Community-global |
A notice about a system channel also has `t` = `system`. Only a filter with
`#t:["system"]` returns it (NIP-CL).
Stored community-globally (`channel_id = None` inside the connected community) so agents and clients can subscribe without knowing channel
UUIDs in advance. Client-submitted kind:44100/44101 events are rejected — only the relay keypair
may sign these.

View File

@@ -32,7 +32,47 @@ pub fn reader_authorized_for_event(event: &nostr::Event, reader_pubkey_hex: &str
.any(|t| t.content() == Some(reader_pubkey_hex))
}
/// The tag that marks a kind:44100/44101 membership notice about a system
/// channel (NIP-CL): `["t", "system"]`. Only the relay signs these kinds, and
/// it adds the tag only when the channel is a system channel.
pub const SYSTEM_NOTICE_TAG: [&str; 2] = ["t", "system"];
/// The kinds that [`SYSTEM_NOTICE_TAG`] can mark.
pub const SYSTEM_NOTICE_KINDS: [u32; 2] = [
crate::kind::KIND_MEMBER_ADDED_NOTIFICATION,
crate::kind::KIND_MEMBER_REMOVED_NOTIFICATION,
];
/// Whether `event` is a membership notice about a system channel.
///
/// Such a notice is hidden by default: it matches only a filter that asks
/// for it ([`filter_asks_for_system_notices`]). Clients that do not know
/// system channels never ask, so they never see one, on any read or live
/// path that matches through [`filters_match`] or applies the same rule in
/// SQL (`EventQuery::include_system_notices`, which defaults to off).
pub fn is_system_notice(event: &nostr::Event) -> bool {
SYSTEM_NOTICE_KINDS.contains(&crate::kind::event_kind_u32(event))
&& event.tags.iter().any(|tag| {
let tag = tag.as_slice();
tag.first().map(String::as_str) == Some(SYSTEM_NOTICE_TAG[0])
&& tag.get(1).map(String::as_str) == Some(SYSTEM_NOTICE_TAG[1])
})
}
/// Whether `filter` asks for system-channel membership notices: its `#t`
/// values include `"system"`.
pub fn filter_asks_for_system_notices(filter: &Filter) -> bool {
filter
.generic_tags
.get(&nostr::SingleLetterTag::lowercase(nostr::Alphabet::T))
.is_some_and(|values| values.iter().any(|value| value == SYSTEM_NOTICE_TAG[1]))
}
fn filter_match_one(f: &Filter, ev: &StoredEvent) -> bool {
if is_system_notice(&ev.event) && !filter_asks_for_system_notices(f) {
return false;
}
if let Some(kinds) = &f.kinds {
if !kinds.contains(&ev.event.kind) {
return false;
@@ -119,6 +159,58 @@ mod tests {
StoredEvent::with_received_at(event, Utc::now(), None, true)
}
#[test]
fn a_system_notice_matches_only_a_filter_that_asks() {
let relay = Keys::generate();
let member = Keys::generate().public_key();
let notice = |kind: u32, system: bool| {
let mut tags = vec![
Tag::parse(["p", &member.to_hex()]).unwrap(),
Tag::parse(["h", "c"]).unwrap(),
];
if system {
tags.push(Tag::parse(SYSTEM_NOTICE_TAG).unwrap());
}
let event = EventBuilder::new(Kind::Custom(kind as u16), "")
.tags(tags)
.sign_with_keys(&relay)
.expect("sign");
StoredEvent::with_received_at(event, Utc::now(), None, true)
};
let filter = |json: serde_json::Value| -> Filter { serde_json::from_value(json).unwrap() };
let p = member.to_hex();
let old = [
filter(serde_json::json!({"kinds": [44100, 44101], "#p": [p]})),
filter(serde_json::json!({"#p": [p]})),
filter(serde_json::json!({})),
filter(serde_json::json!({"#t": ["other"]})),
];
let asks = [
filter(serde_json::json!({"kinds": [44100, 44101], "#p": [p], "#t": ["system"]})),
filter(serde_json::json!({"#t": ["other", "system"]})),
];
for kind in SYSTEM_NOTICE_KINDS {
let system = notice(kind, true);
assert!(is_system_notice(&system.event));
for f in &old {
assert!(!filters_match(std::slice::from_ref(f), &system), "{f:?}");
}
for f in &asks {
assert!(filters_match(std::slice::from_ref(f), &system), "{f:?}");
}
// An ordinary notice is unchanged: old filters see it, and the
// asking filter's `#t` does not match it.
let ordinary = notice(kind, false);
assert!(!is_system_notice(&ordinary.event));
assert!(filters_match(&old[..1], &ordinary));
assert!(!filters_match(&asks[..1], &ordinary));
}
// The tag hides nothing on other kinds.
let other = notice(44200, true);
assert!(!is_system_notice(&other.event));
assert!(filters_match(&old[2..3], &other));
}
#[test]
fn kind_author_since_until_tag_matching() {
let keys = Keys::generate();

View File

@@ -125,6 +125,12 @@ pub struct EventQuery {
/// containment alone would also admit decoys like `["x","shared","true"]`.
/// `event_visible_to_reader` remains the post-filter.
pub shared_gated_reader: Option<Vec<u8>>,
/// Whether results may include membership notices about system channels
/// ([`buzz_core::filter::is_system_notice`]). Off by default, so a read
/// path that does not ask never returns one (NIP-CL). Set it only for a
/// filter that asks for them
/// ([`buzz_core::filter::filter_asks_for_system_notices`]).
pub include_system_notices: bool,
}
impl EventQuery {
@@ -158,6 +164,7 @@ impl EventQuery {
channel_ids_include_global: true,
max_limit: None,
shared_gated_reader: None,
include_system_notices: false,
}
}
}
@@ -655,6 +662,7 @@ fn build_query_events_sql(q: &EventQuery) -> QueryBuilder<sqlx::Postgres> {
let table = if q.p_tag_hex.is_some() { "e" } else { "events" };
qb.push(format!(" AND NOT ({table}.kind = 45010 AND NOT EXISTS (SELECT 1 FROM artifact_heads ah WHERE ah.community_id={table}.community_id AND ah.event_id={table}.id))"));
}
push_system_notice_exclusion(&mut qb, col_prefix, q);
if let Some(ch) = q.channel_id {
qb.push(format!(" AND {col_prefix}channel_id = "))
@@ -912,6 +920,37 @@ fn push_tag_value_filters(
}
}
/// Hide membership notices about system channels unless the query asks for
/// them (NIP-CL): `AND NOT (kind IN (44100, 44101) AND <exact ["t","system"]>)`.
/// The same rule as [`buzz_core::filter::is_system_notice`] in `filters_match`,
/// pushed before `LIMIT` so hidden rows cannot take page slots. Skipped when
/// the query's kinds cannot match a notice.
fn push_system_notice_exclusion(
qb: &mut QueryBuilder<sqlx::Postgres>,
col_prefix: &str,
q: &EventQuery,
) {
use buzz_core::filter::{SYSTEM_NOTICE_KINDS, SYSTEM_NOTICE_TAG};
if q.include_system_notices {
return;
}
if let Some(kinds) = q.kinds.as_deref() {
if !kinds
.iter()
.any(|kind| SYSTEM_NOTICE_KINDS.contains(&(*kind as u32)))
{
return;
}
}
let [added, removed] = SYSTEM_NOTICE_KINDS;
let [name, value] = SYSTEM_NOTICE_TAG;
qb.push(format!(
" AND NOT ({col_prefix}kind IN ({added}, {removed}) AND "
));
push_exact_tag_match(qb, col_prefix, name, &[value.to_owned()]);
qb.push(")");
}
/// Match `#d` on artifact rows before `LIMIT` while leaving other kinds to the
/// caller's post-filter: `(kind NOT IN (artifact kinds) OR <exact d match>)`.
fn push_artifact_d_tag_predicate(
@@ -1030,6 +1069,7 @@ pub(crate) async fn count_events_on(conn: &mut sqlx::PgConnection, q: &EventQuer
let table = if q.p_tag_hex.is_some() { "e" } else { "events" };
qb.push(format!(" AND NOT ({table}.kind = 45010 AND NOT EXISTS (SELECT 1 FROM artifact_heads ah WHERE ah.community_id={table}.community_id AND ah.event_id={table}.id))"));
}
push_system_notice_exclusion(&mut qb, col_prefix, q);
if let Some(ch) = q.channel_id {
qb.push(format!(" AND {col_prefix}channel_id = "))

View File

@@ -1253,9 +1253,24 @@ fn filter_to_query_params(
.filter(|values| !values.is_empty())
.map(|values| values.iter().map(|v| v.to_string()).collect());
let tag_values = group_state_tag_values(filter);
let mut tag_values = group_state_tag_values(filter);
// A filter that asks for system-channel membership notices (`#t` has
// "system") may read them; match its `#t` in SQL so ordinary notices do
// not take its page slots. Every other filter keeps the default, which
// hides them (NIP-CL).
let include_system_notices = buzz_core::filter::filter_asks_for_system_notices(filter);
if include_system_notices && !tag_values.iter().any(|(name, _)| name == "t") {
let t = nostr::SingleLetterTag::lowercase(nostr::Alphabet::T);
if let Some(values) = filter.generic_tags.get(&t) {
tag_values.push((
t.to_string(),
values.iter().map(|value| value.to_string()).collect(),
));
}
}
EventQuery {
include_system_notices,
channel_id,
kinds,
pubkey,

View File

@@ -1059,8 +1059,11 @@ pub fn emit_live_thread_summary(
/// Emit a relay-signed membership notification event stored globally (channel_id = None).
///
/// kind:44100 = member added, kind:44101 = member removed. System channels
/// emit none.
/// kind:44100 = member added, kind:44101 = member removed. A notice about a
/// system channel carries `["t","system"]` (NIP-CL). This is the only place
/// that checks the channel type for notices: every read and live path hides a
/// tagged notice unless the filter asks for it (`buzz_core::filter`), so
/// clients that do not know system channels never see one.
/// The p tag addresses the target pubkey; the h tag carries the channel UUID as metadata.
/// Stored with channel_id = None so global subscribers receive it via slow-path fan-out.
pub async fn emit_membership_notification(
@@ -1071,16 +1074,11 @@ pub async fn emit_membership_notification(
actor_pubkey: &[u8],
notification_kind: u32,
) -> anyhow::Result<()> {
// A system channel is left out of clients' channel lists, so it sends no
// membership notifications: a client that receives one would show it.
if !state
let system = !state
.db
.system_channel_ids_among(tenant.community(), &[channel_id])
.await?
.is_empty()
{
return Ok(());
}
.is_empty();
let target_hex = hex::encode(target_pubkey);
let actor_hex = hex::encode(actor_pubkey);
let channel_id_str = channel_id.to_string();
@@ -1107,8 +1105,15 @@ pub async fn emit_membership_notification(
})
.to_string();
let mut tags = vec![p_tag, h_tag];
if system {
tags.push(
Tag::parse(buzz_core::filter::SYSTEM_NOTICE_TAG)
.map_err(|e| anyhow::anyhow!("failed to build system tag: {e}"))?,
);
}
let event = EventBuilder::new(Kind::Custom(notification_kind as u16), content)
.tags([p_tag, h_tag])
.tags(tags)
.sign_with_keys(&state.relay_keypair)
.map_err(|e| anyhow::anyhow!("failed to sign membership notification: {e}"))?;

View File

@@ -175,7 +175,7 @@ fn bridge_count(body: Option<Value>) -> u64 {
})
}
fn sorted(mut ids: Vec<String>) -> Vec<String> {
fn sorted<T: Ord>(mut ids: Vec<T>) -> Vec<T> {
ids.sort();
ids
}
@@ -428,17 +428,60 @@ async fn named_channels_missing_from_the_cache_are_confirmed_together() {
assert!(readable_system.is_empty());
}
/// Adding a member to a system channel sends no kind:44100 notification,
/// which an existing client would show as a new channel. An ordinary
/// channel still sends one, so the check is not vacuous.
/// Mutation: drop the system check in `emit_membership_notification` → RED.
/// A system channel's membership notices are stored kind:44100/44101 with
/// `["t","system"]`, and only a filter that asks with `#t:["system"]` sees
/// them. Every other filter, on every path, sees only the ordinary channel's
/// notice, as it does today: live fan-out, REQ history, COUNT, and the HTTP
/// bridge.
/// Mutation: drop the tag in `emit_membership_notification` → RED (the
/// opt-in filter finds nothing, and old filters see the system channel);
/// drop the `is_system_notice` check in `filters_match` → RED (live and
/// post-filtered reads show it); drop `push_system_notice_exclusion` → RED
/// (COUNT counts it).
#[tokio::test]
#[ignore = "requires Postgres and Redis"]
async fn system_channels_send_no_membership_notifications() {
async fn system_channel_notices_reach_only_filters_that_ask() {
use buzz_core::kind::{KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION};
let f = fixture().await;
let owner = Keys::generate();
let member = Keys::generate();
let member_hex = member.public_key().to_hex();
let kinds = [
KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION,
];
let old = json!({"kinds": kinds, "#p": [member_hex]});
let asks = json!({"kinds": kinds, "#p": [member_hex], "#t": ["system"]});
// The member's live global subscriptions: one as clients send today, one
// that asks for system notices.
let conn_id = Uuid::new_v4();
let (tx, mut rx) = tokio::sync::mpsc::channel(32);
f.state.conn_manager.register(
conn_id,
tx,
tokio::sync::mpsc::channel(4).0,
tokio::sync::mpsc::channel(1).0,
None,
tokio_util::sync::CancellationToken::new(),
f.tenant.community(),
Arc::new(std::sync::atomic::AtomicU8::new(0)),
Arc::new(tokio::sync::Mutex::new(Default::default())),
3,
crate::state::CommunityConnectionControl::new(tokio_util::sync::CancellationToken::new()),
);
f.state
.conn_manager
.set_authenticated_pubkey(conn_id, member.public_key().to_bytes().to_vec());
for (sub, filter) in [("old", &old), ("asks", &asks)] {
f.state.sub_registry.register_scoped(
f.tenant.community(),
conn_id,
sub.into(),
vec![serde_json::from_value(filter.clone()).expect("filter")],
None,
);
}
let system = create_channel(&f.state, &f.tenant, &owner, &[&["channel_type", "system"]]).await;
let ordinary = create_channel(&f.state, &f.tenant, &owner, &[]).await;
@@ -452,36 +495,186 @@ async fn system_channels_send_no_membership_notifications() {
)
.await;
}
// Notifications are written after the reply; give the task a moment.
let mut notified = Vec::new();
for _ in 0..50 {
notified = f
.state
.db
.query_events(&EventQuery {
kinds: Some(vec![buzz_core::kind::KIND_MEMBER_ADDED_NOTIFICATION as i32]),
p_tag_hex: Some(member_hex.clone()),
..EventQuery::for_community(f.tenant.community())
})
publish(
&f,
&owner,
9001,
"",
&[&["h", &system.to_string()], &["p", &member_hex]],
)
.await;
// Notifications are sent after the reply; collect the three frames.
let mut live = Vec::new();
let mut ids = std::collections::HashMap::new();
while live.len() < 3 {
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
.await
.expect("query notifications")
.into_iter()
.map(|stored| {
stored
.event
.tags
.iter()
.find(|tag| tag.as_slice().first().map(String::as_str) == Some("h"))
.and_then(|tag| tag.as_slice().get(1).cloned())
.expect("h tag")
})
.collect::<Vec<_>>();
if !notified.is_empty() {
.unwrap_or_else(|_| panic!("notices so far: {live:?}"))
.expect("connection open");
let axum::extract::ws::Message::Text(text) = frame else {
continue;
};
let message: Value = serde_json::from_str(&text).expect("relay frame");
if message[0] != "EVENT" {
continue;
}
let event: nostr::Event = serde_json::from_value(message[2].clone()).expect("event");
assert_eq!(
event.pubkey,
f.state.relay_keypair.public_key(),
"relay-signed"
);
let h = event
.tags
.iter()
.find(|tag| tag.as_slice().first().map(String::as_str) == Some("h"))
.and_then(|tag| tag.as_slice().get(1).cloned())
.expect("h tag");
let kind = u32::from(event.kind.as_u16());
ids.insert((kind, h.clone()), event.id.to_hex());
live.push((message[1].as_str().expect("sub id").to_owned(), kind, h));
}
assert!(
tokio::time::timeout(std::time::Duration::from_millis(200), rx.recv())
.await
.is_err(),
"no fourth notice"
);
assert_eq!(
sorted(live),
sorted(vec![
(
"asks".into(),
KIND_MEMBER_ADDED_NOTIFICATION,
system.to_string()
),
(
"asks".into(),
KIND_MEMBER_REMOVED_NOTIFICATION,
system.to_string()
),
(
"old".into(),
KIND_MEMBER_ADDED_NOTIFICATION,
ordinary.to_string()
),
])
);
let ordinary_added = ids[&(KIND_MEMBER_ADDED_NOTIFICATION, ordinary.to_string())].clone();
let system_notices = sorted(vec![
ids[&(KIND_MEMBER_ADDED_NOTIFICATION, system.to_string())].clone(),
ids[&(KIND_MEMBER_REMOVED_NOTIFICATION, system.to_string())].clone(),
]);
// History: the system notices are stored, and only the asking filter
// reads them.
assert_eq!(
req(&f, &member, old.clone()).await,
std::slice::from_ref(&ordinary_added)
);
assert_eq!(count(&f, &member, old.clone()).await, 1);
assert_eq!(
bridge_ids(bridge(&f, &member, "/query", json!([old])).await),
std::slice::from_ref(&ordinary_added)
);
assert_eq!(
bridge_count(bridge(&f, &member, "/count", json!([old])).await),
1
);
assert_eq!(sorted(req(&f, &member, asks.clone()).await), system_notices);
assert_eq!(count(&f, &member, asks.clone()).await, 2);
assert_eq!(
sorted(bridge_ids(
bridge(&f, &member, "/query", json!([asks])).await
)),
system_notices
);
// Replay with `since` works for the asking filter.
let mut since = asks.clone();
since["since"] = json!(0);
assert_eq!(sorted(req(&f, &member, since).await), system_notices);
}
/// The database hides system notices unless the query asks, so a read path
/// that builds its own `EventQuery` and forgets the rule still hides them.
/// Mutation: drop `push_system_notice_exclusion` → RED.
#[tokio::test]
#[ignore = "requires Postgres and Redis"]
async fn the_database_hides_system_notices_by_default() {
use buzz_core::kind::KIND_MEMBER_ADDED_NOTIFICATION;
let f = fixture().await;
let owner = Keys::generate();
let member = Keys::generate();
let member_hex = member.public_key().to_hex();
let system = create_channel(&f.state, &f.tenant, &owner, &[&["channel_type", "system"]]).await;
let ordinary = create_channel(&f.state, &f.tenant, &owner, &[]).await;
for channel in [system, ordinary] {
publish(
&f,
&owner,
9000,
"",
&[&["h", &channel.to_string()], &["p", &member_hex]],
)
.await;
}
// Kindless, but global only: the notices are the only global events
// that mention the member.
let base = EventQuery {
p_tag_hex: Some(member_hex.clone()),
global_only: true,
..EventQuery::for_community(f.tenant.community())
};
let channels_of = |events: Vec<buzz_core::StoredEvent>| {
sorted(
events
.into_iter()
.map(|stored| {
stored
.event
.tags
.iter()
.find(|tag| tag.as_slice().first().map(String::as_str) == Some("h"))
.and_then(|tag| tag.as_slice().get(1).cloned())
.expect("h tag")
})
.collect(),
)
};
// Notifications are written after the reply; wait for both.
let mut all = Vec::new();
for _ in 0..50 {
all = channels_of(
f.state
.db
.query_events(&EventQuery {
include_system_notices: true,
..base.clone()
})
.await
.expect("query"),
);
if all.len() == 2 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert_eq!(sorted(notified), vec![ordinary.to_string()]);
assert_eq!(all, sorted(vec![system.to_string(), ordinary.to_string()]));
for query in [
base.clone(),
EventQuery {
kinds: Some(vec![KIND_MEMBER_ADDED_NOTIFICATION as i32]),
..base.clone()
},
] {
assert_eq!(
channels_of(f.state.db.query_events(&query).await.expect("query")),
[ordinary.to_string()]
);
assert_eq!(f.state.db.count_events(&query).await.expect("count"), 1);
}
}
/// A group-state filter with `#t:["system"]` or any `#P` finds the system

View File

@@ -186,9 +186,34 @@ a system channel stays an ordinary NIP-29 group.
The relay decides this for each filter. A filter that names a system channel
does not add it to another filter in the same request.
The relay MUST NOT send push notifications or kind:44100/44101 membership
notifications for system channels. A client that received one would show the
channel.
The relay MUST NOT send push notifications for system channels.
### Membership notices for system channels
When a member is added to or removed from a system channel, the relay sends
the usual kind:44100 or kind:44101 notice with one more tag,
`["t", "system"]`. It stores the notice like any other 44100/44101.
A client that does not know system channels would show the channel from such
a notice. So the relay MUST return a notice with `["t", "system"]` only to a
filter whose `#t` has the value `system`. This applies on every path: REQ
history, live delivery, COUNT, and any relay-specific query API. Existing
clients do not send `#t` on these kinds, so they see no change.
A client that knows system channels sends one more filter:
```json
{"kinds": [44100, 44101], "#p": ["<me>"], "#t": ["system"]}
```
The `#p` rule for 44100/44101 still applies: it must be the reader's own
pubkey. The tag comes from the channel type when the relay sends the notice.
Because the notices are stored, a client that reconnects can set `since` to
the newest notice it has seen. One subscription then returns the notices it
missed and the new ones, so no change falls between a read and a
subscription. A notice is a hint: the channel's group-state events decide
membership.
### Asking for system channels