mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
13
NOSTR.md
13
NOSTR.md
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = "))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user