From 48b7fa405cacd625a4f0befdd1d30e690bfe54fc Mon Sep 17 00:00:00 2001 From: Toos00 <164030755+Toos00@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:45:52 +0000 Subject: [PATCH] fix(channels): preserve canonical member rosters Signed-off-by: Toos00 <164030755+Toos00@users.noreply.github.com> --- crates/buzz-admin/src/main.rs | 24 +++- .../buzz-relay/src/handlers/side_effects.rs | 121 +++++++++++++++++- 2 files changed, 142 insertions(+), 3 deletions(-) diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 19a3b1d9d48..76bb0722d57 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -508,6 +508,7 @@ async fn reconcile_channels( k } }; + let relay_pubkey = relay_keys.public_key().to_bytes(); let tenant = resolve_admin_tenant(&db).await?; let target_channel = channel_arg @@ -609,17 +610,36 @@ async fn reconcile_channels( // kind:39002 — members { + let mut member_snapshot = db + .lock_member_snapshot(tenant.community(), channel.id, &relay_pubkey) + .await?; let mut tags: Vec = vec![Tag::parse(["d", &channel_id_str])?]; - for m in &members { + let mut canonical_members = member_snapshot.members.iter().collect::>(); + canonical_members.sort_by(|a, b| a.pubkey.cmp(&b.pubkey)); + for m in canonical_members { let pk = hex::encode(&m.pubkey); tags.push(Tag::parse(["p", &pk, "", &m.role])?); } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let ts = member_snapshot + .latest_member_event_timestamp(tenant.community(), channel.id, &relay_pubkey) + .await? + .map(|timestamp| timestamp + 1) + .unwrap_or(now) + .max(now); let event = EventBuilder::new(Kind::Custom(39002), "") .tags(tags) + .custom_created_at(nostr::Timestamp::from(ts)) + .allow_self_tagging() .sign_with_keys(&relay_keys) .map_err(|e| anyhow::anyhow!("sign kind:39002: {e}"))?; - db.replace_addressable_event(tenant.community(), &event, Some(channel.id)) + member_snapshot + .replace_member_event(tenant.community(), channel.id, &event) .await?; + member_snapshot.release().await?; } reconciled += 1; diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index d3416d673c5..b1e21d0c437 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1050,7 +1050,9 @@ async fn emit_addressable_discovery_event( fn group_members_tags(group_id: &str, members: &[MemberRecord]) -> anyhow::Result> { let mut tags: Vec = Vec::with_capacity(members.len() + 1); tags.push(Tag::parse(["d", group_id])?); - for member in members { + let mut canonical_members = members.iter().collect::>(); + canonical_members.sort_by(|a, b| a.pubkey.cmp(&b.pubkey)); + for member in canonical_members { let pubkey_hex = hex::encode(&member.pubkey); // NIP-29 convention: ["p", pubkey, relay_url, role]. Empty relay_url // because the canonical relay is implicit (this event is signed by it). @@ -3683,6 +3685,7 @@ pub async fn publish_nipia_unarchived( #[cfg(test)] mod tests { use super::*; + use nostr::Keys; #[test] fn group_members_snapshot_keeps_members_past_one_thousand() { @@ -3711,6 +3714,122 @@ mod tests { })); } + #[test] + fn group_members_snapshot_uses_canonical_pubkey_order() { + let channel_id = Uuid::new_v4(); + let members = vec![ + MemberRecord { + channel_id, + pubkey: vec![0xff; 32], + role: "member".to_string(), + joined_at: chrono::Utc::now(), + invited_by: None, + removed_at: None, + }, + MemberRecord { + channel_id, + pubkey: vec![0x01; 32], + role: "owner".to_string(), + joined_at: chrono::Utc::now(), + invited_by: None, + removed_at: None, + }, + MemberRecord { + channel_id, + pubkey: vec![0x80; 32], + role: "bot".to_string(), + joined_at: chrono::Utc::now(), + invited_by: None, + removed_at: None, + }, + ]; + + let tags = group_members_tags(&channel_id.to_string(), &members).expect("build tags"); + let p_tags = tags + .iter() + .filter_map(|tag| { + let fields = tag.as_slice(); + (fields.first().map(String::as_str) == Some("p")).then(|| { + ( + fields.get(1).cloned(), + fields.get(2).cloned(), + fields.get(3).cloned(), + ) + }) + }) + .collect::>(); + + assert_eq!( + p_tags, + vec![ + ( + Some(hex::encode(vec![0x01; 32])), + Some(String::new()), + Some("owner".to_string()) + ), + ( + Some(hex::encode(vec![0x80; 32])), + Some(String::new()), + Some("bot".to_string()) + ), + ( + Some(hex::encode(vec![0xff; 32])), + Some(String::new()), + Some("member".to_string()) + ), + ] + ); + } + + #[test] + fn group_members_snapshot_preserves_relay_self_tag() { + let relay_keys = Keys::generate(); + let relay_pubkey = relay_keys.public_key().to_bytes(); + let relay_pubkey_hex = hex::encode(relay_pubkey); + let channel_id = Uuid::new_v4(); + let members = vec![ + MemberRecord { + channel_id, + pubkey: relay_pubkey.to_vec(), + role: "owner".to_string(), + joined_at: chrono::Utc::now(), + invited_by: None, + removed_at: None, + }, + MemberRecord { + channel_id, + pubkey: vec![0x80; 32], + role: "bot".to_string(), + joined_at: chrono::Utc::now(), + invited_by: None, + removed_at: None, + }, + ]; + + let tags = group_members_tags(&channel_id.to_string(), &members).expect("build tags"); + let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_MEMBERS as u16), "") + .tags(tags) + .allow_self_tagging() + .sign_with_keys(&relay_keys) + .expect("sign member snapshot"); + let p_tags = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("p")) + .collect::>(); + + assert_eq!(p_tags.len(), 2); + assert!(p_tags.iter().any(|tag| { + tag.as_slice() + == [ + "p".to_string(), + relay_pubkey_hex.clone(), + String::new(), + "owner".to_string(), + ] + })); + } + #[test] fn delete_tombstone_omits_absent_moderation_metadata() { let content =