feat(relay): tag every membership notice with its channel type

Every kind:44100/44101 notice now carries `["t", <channel_type>]`. A
filter sees a notice only if it knows the type: a filter with `#t` sees
the types it names, and a filter without `#t` sees only the frozen legacy
types (stream, forum, dm, workflow). A type added later is hidden from old
clients by default, so a read path that is not updated fails closed.

An untagged notice, stored before this change, counts as a legacy type, so
replay across the upgrade stays complete. One filter that names every type
reads all notices.

The rule lives in buzz-core (`notice_visible`) and in one SQL predicate
(`push_notice_visibility`), which defaults to legacy types. The relay reads
the channel type in one place, `emit_membership_notification`, and sends no
notice when the channel row is missing.

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 15:54:19 -04:00
parent 66a12c1755
commit f24f8e7ba3
8 changed files with 372 additions and 156 deletions

View File

@@ -152,9 +152,10 @@ out unless they ask for it:
combine with OR, so `#t:["system", <label>]` does not mean "a system
channel with this label".
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).
Push notifications skip system channels. Every kind:44100/44101 membership
notification carries its channel type as `t`, and a filter without `#t` sees
only the legacy types, so system channel notices reach only 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:
@@ -180,8 +181,10 @@ 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).
Each notice also has `t` = the channel type (`stream`, `forum`, `dm`,
`workflow`, `system`). A filter with `#t` gets only the types it names. A
filter without `#t` gets only `stream`, `forum`, `dm` and `workflow`, and so
does an untagged notice stored before the tag existed (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

View File

@@ -32,44 +32,74 @@ 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] = [
/// The membership notice kinds (44100 added, 44101 removed). Only the relay
/// signs them, and it tags each one with its channel's type:
/// `["t", <channel_type>]` (NIP-CL).
pub const MEMBERSHIP_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.
/// The tag name that carries a membership notice's channel type.
pub const NOTICE_CHANNEL_TYPE_TAG: &str = "t";
/// The channel types that clients knew before NIP-CL typed membership
/// notices. A filter without `#t` sees notices only about these types, and a
/// notice without a type tag (stored before NIP-CL) counts as all of them.
///
/// 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])
})
/// Frozen. Do not add a type here: a type added later stays hidden from every
/// client that does not name it in `#t`.
pub const LEGACY_NOTICE_CHANNEL_TYPES: [&str; 4] = ["stream", "forum", "dm", "workflow"];
/// Whether `event` is a membership notice (kind 44100/44101).
pub fn is_membership_notice(event: &nostr::Event) -> bool {
MEMBERSHIP_NOTICE_KINDS.contains(&crate::kind::event_kind_u32(event))
}
/// Whether `filter` asks for system-channel membership notices: its `#t`
/// values include `"system"`.
pub fn filter_asks_for_system_notices(filter: &Filter) -> bool {
/// The channel types whose membership notices `filter` may see: its `#t`
/// values, or [`LEGACY_NOTICE_CHANNEL_TYPES`] when it has no `#t`.
///
/// This is the one rule for notice visibility (NIP-CL). [`filters_match`]
/// applies it, and `EventQuery::notice_channel_types` applies it in SQL.
pub fn notice_channel_types(filter: &Filter) -> Option<Vec<String>> {
filter
.generic_tags
.get(&nostr::SingleLetterTag::lowercase(nostr::Alphabet::T))
.is_some_and(|values| values.iter().any(|value| value == SYSTEM_NOTICE_TAG[1]))
.map(|values| values.iter().map(ToString::to_string).collect())
}
/// Whether a filter whose visible types are `visible` (see
/// [`notice_channel_types`]) may see membership notice `event`.
///
/// A tagged notice matches when one of its types is visible. An untagged
/// notice is a legacy type, so it matches when a legacy type is visible.
pub fn notice_visible(visible: Option<&[String]>, event: &nostr::Event) -> bool {
let is_visible = |channel_type: &str| match visible {
Some(types) => types.iter().any(|t| t == channel_type),
None => LEGACY_NOTICE_CHANNEL_TYPES.contains(&channel_type),
};
let mut tagged = false;
for tag in event.tags.iter() {
let tag = tag.as_slice();
if tag.first().map(String::as_str) != Some(NOTICE_CHANNEL_TYPE_TAG) {
continue;
}
tagged = true;
if tag
.get(1)
.is_some_and(|channel_type| is_visible(channel_type))
{
return true;
}
}
!tagged && LEGACY_NOTICE_CHANNEL_TYPES.iter().any(|t| is_visible(t))
}
fn filter_match_one(f: &Filter, ev: &StoredEvent) -> bool {
if is_system_notice(&ev.event) && !filter_asks_for_system_notices(f) {
// A membership notice's `#t` is its channel type, matched by the notice
// rule above instead of the plain tag match below.
let notice = is_membership_notice(&ev.event);
if notice && !notice_visible(notice_channel_types(f).as_deref(), &ev.event) {
return false;
}
@@ -107,6 +137,9 @@ fn filter_match_one(f: &Filter, ev: &StoredEvent) -> bool {
for (tag_key, tag_values) in f.generic_tags.iter() {
let tag_key_str = tag_key.to_string();
if notice && tag_key_str == NOTICE_CHANNEL_TYPE_TAG {
continue;
}
let has_match = tag_values.iter().any(|filter_val| {
ev.event
.tags
@@ -160,16 +193,16 @@ mod tests {
}
#[test]
fn a_system_notice_matches_only_a_filter_that_asks() {
fn a_notice_matches_only_filters_that_know_its_channel_type() {
let relay = Keys::generate();
let member = Keys::generate().public_key();
let notice = |kind: u32, system: bool| {
let notice = |kind: u32, channel_type: Option<&str>| {
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());
if let Some(channel_type) = channel_type {
tags.push(Tag::parse([NOTICE_CHANNEL_TYPE_TAG, channel_type]).unwrap());
}
let event = EventBuilder::new(Kind::Custom(kind as u16), "")
.tags(tags)
@@ -179,36 +212,66 @@ mod tests {
};
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 all = ["stream", "forum", "dm", "workflow", "system"];
// (filter, sees untagged, stream, system, a type added later)
let cases = [
(
serde_json::json!({"kinds": [44100, 44101], "#p": [p]}),
[true, true, false, false],
),
(serde_json::json!({"#p": [p]}), [true, true, false, false]),
(serde_json::json!({}), [true, true, false, false]),
(
serde_json::json!({"#t": ["other"]}),
[false, false, false, false],
),
(
serde_json::json!({"kinds": [44100, 44101], "#p": [p], "#t": all}),
[true, true, true, false],
),
(
serde_json::json!({"#t": ["system"]}),
[false, false, true, false],
),
(
serde_json::json!({"#t": ["dm"]}),
[true, false, false, false],
),
(
serde_json::json!({"#t": ["future"]}),
[false, false, false, true],
),
];
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 kind in MEMBERSHIP_NOTICE_KINDS {
let notices = [
notice(kind, None),
notice(kind, Some("stream")),
notice(kind, Some("system")),
notice(kind, Some("future")),
];
for (json, expected) in &cases {
let f = filter(json.clone());
for (ev, expected) in notices.iter().zip(expected) {
assert_eq!(
filters_match(std::slice::from_ref(&f), ev),
*expected,
"kind {kind}, filter {json}, notice {:?}",
ev.event.tags
);
}
}
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));
// Other kinds keep the plain `#t` match.
let other = notice(44200, Some("system"));
assert!(filters_match(&[filter(serde_json::json!({}))], &other));
assert!(filters_match(
&[filter(serde_json::json!({"#t": ["system"]}))],
&other
));
assert!(!filters_match(
&[filter(serde_json::json!({"#t": ["stream"]}))],
&other
));
}
#[test]

View File

@@ -1288,8 +1288,29 @@ pub async fn get_readable_system_channel_ids(
.collect()
}
/// The type of channel `channel_id`, deleted or not, or `None` when no such
/// channel exists. Membership notices carry it as their `t` tag (NIP-CL).
pub async fn channel_type_of(
pool: &PgPool,
community_id: CommunityId,
channel_id: Uuid,
) -> Result<Option<String>> {
let mut connection = crate::observability::acquire_writer(
pool,
crate::observability::WriterOperation::Authorization,
)
.await?;
Ok(sqlx::query_scalar(
"SELECT channel_type::text FROM channels WHERE community_id = $1 AND id = $2",
)
.bind(community_id.as_uuid())
.bind(channel_id)
.fetch_optional(&mut *connection)
.await?)
}
/// The IDs in `channel_ids` that name system channels, deleted or not.
/// Push matching and membership notifications use it to skip them.
/// Push matching uses it to skip them.
pub async fn system_channel_ids_among(
pool: &PgPool,
community_id: CommunityId,
@@ -1954,6 +1975,17 @@ impl Db {
get_readable_system_channel_ids(&self.pool, community_id, pubkey).await
}
/// The type of channel `channel_id`, deleted or not, or `None` when no
/// such channel exists.
#[datastore_span(name = "channel_type_of", system = "postgresql")]
pub async fn channel_type_of(
&self,
community_id: CommunityId,
channel_id: Uuid,
) -> Result<Option<String>> {
channel_type_of(&self.pool, community_id, channel_id).await
}
/// The IDs in `channel_ids` that name system channels, deleted or not.
#[datastore_span(name = "system_channel_ids_among", system = "postgresql")]
pub async fn system_channel_ids_among(

View File

@@ -125,12 +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,
/// The channel types whose membership notices (44100/44101) the query may
/// return: the filter's `#t` values
/// ([`buzz_core::filter::notice_channel_types`]). `None` means the legacy
/// types only, so a read path that does not set it never returns a notice
/// about a type added later, such as a system channel (NIP-CL).
pub notice_channel_types: Option<Vec<String>>,
}
impl EventQuery {
@@ -164,7 +164,7 @@ impl EventQuery {
channel_ids_include_global: true,
max_limit: None,
shared_gated_reader: None,
include_system_notices: false,
notice_channel_types: None,
}
}
}
@@ -662,7 +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);
push_notice_visibility(&mut qb, col_prefix, q);
if let Some(ch) = q.channel_id {
qb.push(format!(" AND {col_prefix}channel_id = "))
@@ -920,34 +920,52 @@ 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;
}
/// Return only membership notices whose channel type the query may see
/// (NIP-CL), as [`buzz_core::filter::notice_visible`] does in `filters_match`:
/// `AND (kind NOT IN (44100, 44101) OR <type tag in visible types>
/// [OR <no type tag>])`. An untagged notice is a legacy type, so it is kept
/// only when a legacy type is visible. Pushed before `LIMIT` so hidden rows
/// cannot take page slots. Skipped when the query's kinds cannot match a
/// notice.
fn push_notice_visibility(qb: &mut QueryBuilder<sqlx::Postgres>, col_prefix: &str, q: &EventQuery) {
use buzz_core::filter::{
LEGACY_NOTICE_CHANNEL_TYPES, MEMBERSHIP_NOTICE_KINDS, NOTICE_CHANNEL_TYPE_TAG,
};
if let Some(kinds) = q.kinds.as_deref() {
if !kinds
.iter()
.any(|kind| SYSTEM_NOTICE_KINDS.contains(&(*kind as u32)))
.any(|kind| MEMBERSHIP_NOTICE_KINDS.contains(&(*kind as u32)))
{
return;
}
}
let [added, removed] = SYSTEM_NOTICE_KINDS;
let [name, value] = SYSTEM_NOTICE_TAG;
let visible: Vec<String> = match &q.notice_channel_types {
Some(types) => types.clone(),
None => LEGACY_NOTICE_CHANNEL_TYPES
.iter()
.map(|t| (*t).to_owned())
.collect(),
};
let untagged_visible = visible
.iter()
.any(|t| LEGACY_NOTICE_CHANNEL_TYPES.contains(&t.as_str()));
let [added, removed] = MEMBERSHIP_NOTICE_KINDS;
qb.push(format!(
" AND NOT ({col_prefix}kind IN ({added}, {removed}) AND "
" AND ({col_prefix}kind NOT IN ({added}, {removed}) OR "
));
push_exact_tag_match(qb, col_prefix, name, &[value.to_owned()]);
if visible.is_empty() {
qb.push("FALSE");
} else {
push_exact_tag_match(qb, col_prefix, NOTICE_CHANNEL_TYPE_TAG, &visible);
}
if untagged_visible {
qb.push(format!(
" OR NOT EXISTS (SELECT 1 FROM jsonb_array_elements({col_prefix}tags) AS tag \
WHERE tag->>0 = "
))
.push_bind(NOTICE_CHANNEL_TYPE_TAG.to_owned())
.push(")");
}
qb.push(")");
}
@@ -1069,7 +1087,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);
push_notice_visibility(&mut qb, col_prefix, q);
if let Some(ch) = q.channel_id {
qb.push(format!(" AND {col_prefix}channel_id = "))

View File

@@ -1252,24 +1252,13 @@ fn filter_to_query_params(
.filter(|values| !values.is_empty())
.map(|values| values.iter().map(|v| v.to_string()).collect());
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(),
));
}
}
let tag_values = group_state_tag_values(filter);
// NIP-CL: a membership notice's `#t` is its channel type. SQL keeps only
// the types this filter may see; a filter without `#t` sees legacy types.
let notice_channel_types = buzz_core::filter::notice_channel_types(filter);
EventQuery {
include_system_notices,
notice_channel_types,
channel_id,
kinds,
pubkey,

View File

@@ -1059,11 +1059,12 @@ 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. 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.
/// kind:44100 = member added, kind:44101 = member removed. Every notice
/// carries its channel's type as `["t", <channel_type>]` (NIP-CL). This is
/// the only place that reads the channel type for notices. Every read and live
/// path shows a notice only to a filter that knows its type
/// (`buzz_core::filter::notice_visible`), so a client never sees a notice
/// about a type it does not know, such as a system channel.
/// 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(
@@ -1074,11 +1075,16 @@ pub async fn emit_membership_notification(
actor_pubkey: &[u8],
notification_kind: u32,
) -> anyhow::Result<()> {
let system = !state
// No channel row means no type to tag. Send nothing rather than an
// untagged notice, which every client would treat as a legacy type.
let Some(channel_type) = state
.db
.system_channel_ids_among(tenant.community(), &[channel_id])
.channel_type_of(tenant.community(), channel_id)
.await?
.is_empty();
else {
warn!(%channel_id, "membership notice skipped: channel not found");
return Ok(());
};
let target_hex = hex::encode(target_pubkey);
let actor_hex = hex::encode(actor_pubkey);
let channel_id_str = channel_id.to_string();
@@ -1105,15 +1111,10 @@ 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 type_tag = Tag::parse([buzz_core::filter::NOTICE_CHANNEL_TYPE_TAG, &channel_type])
.map_err(|e| anyhow::anyhow!("failed to build channel type tag: {e}"))?;
let event = EventBuilder::new(Kind::Custom(notification_kind as u16), content)
.tags(tags)
.tags([p_tag, h_tag, type_tag])
.sign_with_keys(&state.relay_keypair)
.map_err(|e| anyhow::anyhow!("failed to sign membership notification: {e}"))?;

View File

@@ -428,19 +428,20 @@ async fn named_channels_missing_from_the_cache_are_confirmed_together() {
assert!(readable_system.is_empty());
}
/// 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).
/// Every membership notice is a stored kind:44100/44101 tagged with its
/// channel type (`["t","stream"]`, `["t","system"]`). A filter sees a notice
/// only when it knows the type: a filter without `#t` (every client today)
/// sees the legacy types, and a filter with `#t` sees the types it names. One
/// filter can name all types. This holds on every path: live fan-out, REQ
/// history, COUNT, and the HTTP bridge. An untagged notice stored before the
/// tag existed counts as a legacy type.
/// Mutation: drop the type tag in `emit_membership_notification` → RED;
/// drop the `notice_visible` check in `filters_match` → RED (live and
/// post-filtered reads show the system notice); drop the untagged clause in
/// `push_notice_visibility` → RED (the legacy notice disappears).
#[tokio::test]
#[ignore = "requires Postgres and Redis"]
async fn system_channel_notices_reach_only_filters_that_ask() {
async fn membership_notices_reach_only_filters_that_know_their_type() {
use buzz_core::kind::{KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION};
let f = fixture().await;
let owner = Keys::generate();
@@ -452,9 +453,14 @@ async fn system_channel_notices_reach_only_filters_that_ask() {
];
let old = json!({"kinds": kinds, "#p": [member_hex]});
let asks = json!({"kinds": kinds, "#p": [member_hex], "#t": ["system"]});
let all = json!({
"kinds": kinds,
"#p": [member_hex],
"#t": ["stream", "forum", "dm", "workflow", "system"],
});
// The member's live global subscriptions: one as clients send today, one
// that asks for system notices. An outsider also holds a subscription
// that asks for system notices, and one that names every type. An outsider also holds a subscription
// that asks. It is registered directly, as if some filter (for example a
// kindless `ids` lookup) had been admitted, so only the fan-out gate can
// keep the notices from it.
@@ -490,7 +496,7 @@ async fn system_channel_notices_reach_only_filters_that_ask() {
}
rx
};
let mut rx = register(&member, &[("old", &old), ("asks", &asks)]);
let mut rx = register(&member, &[("old", &old), ("asks", &asks), ("all", &all)]);
let outsider = Keys::generate();
let mut outsider_rx = register(&outsider, &[("outsider", &json!({"#t": ["system"]}))]);
@@ -515,10 +521,10 @@ async fn system_channel_notices_reach_only_filters_that_ask() {
)
.await;
// Notifications are sent after the reply; collect the three frames.
// Notifications are sent after the reply; collect the six frames.
let mut live = Vec::new();
let mut ids = std::collections::HashMap::new();
while live.len() < 3 {
while live.len() < 6 {
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
.await
.unwrap_or_else(|_| panic!("notices so far: {live:?}"))
@@ -542,6 +548,17 @@ async fn system_channel_notices_reach_only_filters_that_ask() {
.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 channel_type = event
.tags
.iter()
.find(|tag| tag.as_slice().first().map(String::as_str) == Some("t"))
.and_then(|tag| tag.as_slice().get(1).cloned());
let expected_type = if h == system.to_string() {
"system"
} else {
"stream"
};
assert_eq!(channel_type.as_deref(), Some(expected_type), "type 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));
@@ -550,7 +567,7 @@ async fn system_channel_notices_reach_only_filters_that_ask() {
tokio::time::timeout(std::time::Duration::from_millis(200), rx.recv())
.await
.is_err(),
"no fourth notice"
"no seventh notice"
);
assert_eq!(
sorted(live),
@@ -570,6 +587,21 @@ async fn system_channel_notices_reach_only_filters_that_ask() {
KIND_MEMBER_ADDED_NOTIFICATION,
ordinary.to_string()
),
(
"all".into(),
KIND_MEMBER_ADDED_NOTIFICATION,
system.to_string()
),
(
"all".into(),
KIND_MEMBER_REMOVED_NOTIFICATION,
system.to_string()
),
(
"all".into(),
KIND_MEMBER_ADDED_NOTIFICATION,
ordinary.to_string()
),
])
);
let ordinary_added = ids[&(KIND_MEMBER_ADDED_NOTIFICATION, ordinary.to_string())].clone();
@@ -605,6 +637,56 @@ async fn system_channel_notices_reach_only_filters_that_ask() {
let mut since = asks.clone();
since["since"] = json!(0);
assert_eq!(sorted(req(&f, &member, since).await), system_notices);
// One filter that names every type reads all three notices.
let every_notice = {
let mut ids = system_notices.clone();
ids.push(ordinary_added.clone());
sorted(ids)
};
assert_eq!(sorted(req(&f, &member, all.clone()).await), every_notice);
assert_eq!(count(&f, &member, all.clone()).await, 3);
assert_eq!(
sorted(bridge_ids(
bridge(&f, &member, "/query", json!([all])).await
)),
every_notice
);
// A notice stored before notices had a type tag counts as a legacy type:
// filters without `#t`, and filters that name a legacy type, read it.
let legacy_channel = Uuid::new_v4().to_string();
let legacy = nostr::EventBuilder::new(
nostr::Kind::Custom(KIND_MEMBER_ADDED_NOTIFICATION as u16),
"",
)
.tags([
nostr::Tag::parse(["p", &member_hex]).unwrap(),
nostr::Tag::parse(["h", &legacy_channel]).unwrap(),
])
.sign_with_keys(&f.state.relay_keypair)
.expect("sign");
f.state
.db
.insert_event(f.tenant.community(), &legacy, None)
.await
.expect("store legacy notice");
let legacy_id = legacy.id.to_hex();
let with_legacy = |ids: &[String]| {
let mut ids = ids.to_vec();
ids.push(legacy_id.clone());
sorted(ids)
};
assert_eq!(
sorted(req(&f, &member, old.clone()).await),
with_legacy(std::slice::from_ref(&ordinary_added))
);
assert_eq!(count(&f, &member, old.clone()).await, 2);
assert_eq!(
sorted(req(&f, &member, all.clone()).await),
with_legacy(&every_notice)
);
assert_eq!(count(&f, &member, all.clone()).await, 4);
assert_eq!(sorted(req(&f, &member, asks.clone()).await), system_notices);
// Knowing a notice's id is not authorization: only its target reads,
// counts or receives it, with or without `kinds`.
@@ -641,12 +723,13 @@ async fn system_channel_notices_reach_only_filters_that_ask() {
assert_eq!(sorted(req(&f, &member, by_id(false)).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.
/// The database returns only legacy-type notices unless the query names
/// other types, so a read path that builds its own `EventQuery` and forgets
/// the rule still hides system notices.
/// Mutation: drop `push_notice_visibility` → RED.
#[tokio::test]
#[ignore = "requires Postgres and Redis"]
async fn the_database_hides_system_notices_by_default() {
async fn the_database_returns_only_legacy_type_notices_by_default() {
use buzz_core::kind::KIND_MEMBER_ADDED_NOTIFICATION;
let f = fixture().await;
let owner = Keys::generate();
@@ -694,7 +777,7 @@ async fn the_database_hides_system_notices_by_default() {
f.state
.db
.query_events(&EventQuery {
include_system_notices: true,
notice_channel_types: Some(vec!["stream".into(), "system".into()]),
..base.clone()
})
.await
@@ -720,6 +803,18 @@ async fn the_database_hides_system_notices_by_default() {
);
assert_eq!(f.state.db.count_events(&query).await.expect("count"), 1);
}
let only_system = EventQuery {
notice_channel_types: Some(vec!["system".into()]),
..base.clone()
};
assert_eq!(
channels_of(f.state.db.query_events(&only_system).await.expect("query")),
[system.to_string()]
);
assert_eq!(
f.state.db.count_events(&only_system).await.expect("count"),
1
);
}
/// A group-state filter with `#t:["system"]` or any `#P` finds the system

View File

@@ -188,29 +188,44 @@ does not add it to another filter in the same request.
The relay MUST NOT send push notifications for system channels.
### Membership notices for system channels
### Membership notices carry the channel type
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.
Every kind:44100 (added) and kind:44101 (removed) notice has one more tag,
`["t", <channel_type>]`, for example `["t", "stream"]` or `["t", "system"]`.
The relay sets it from the channel type when it sends the notice. A channel's
type does not change. The relay 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 sees a notice only if it knows the notice's channel type. Old
clients do not know types added later, such as `system`, and would show the
channel from such a notice. So the relay MUST apply this rule:
A client that knows system channels sends one more filter:
- **A filter with `#t`** matches a notice only when the notice's type is one
of the `#t` values.
- **A filter without `#t`** matches a notice only when its type is one of the
**legacy types**: `stream`, `forum`, `dm`, `workflow`. This list is fixed.
A type added later is never on it, so old clients never see its notices.
- **A notice without a `t` tag** was stored before this rule. Every such
notice is about a legacy type, so the relay treats it as all of them: it
matches a filter without `#t`, and a filter whose `#t` has a legacy type.
On these two kinds, this replaces the plain NIP-01 `#t` match.
The rule 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 every current type sends one filter:
```json
{"kinds": [44100, 44101], "#p": ["<me>"], "#t": ["system"]}
{"kinds": [44100, 44101], "#p": ["<me>"], "#t": ["stream", "forum", "dm", "workflow", "system"]}
```
When Buzz adds a type, a client adds its value to `#t` when it supports that
type.
The `#p` rule for 44100/44101 still applies: it must be the reader's own
pubkey. An `ids` filter does not lift it: the relay returns, counts or
delivers a 44100/44101 only to the pubkey in its `p` tag, even to a reader
who knows the event id. The tag comes from the channel type when the relay
sends the notice.
who knows the event id.
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