mirror of
https://github.com/block/buzz.git
synced 2026-11-05 00:41:28 +00:00
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:
7
NOSTR.md
7
NOSTR.md
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = "))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user