use serde::{Deserialize, Serialize};
use super::{Channel, ChannelId, ChannelKey, Community, CommunityId, Epoch, ServerRootKey};
use crate::stored_event::event_kind;
use nostr_sdk::prelude::{EventBuilder, Kind, PublicKey, UnsignedEvent};
fn is_zero_u64(n: &u64) -> bool {
*n == 0
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InviteChannel {
pub id: String,
pub key: String,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub epoch: u64,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommunityInvite {
pub community_id: String,
pub name: String,
pub server_root_key: String,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub server_root_epoch: u64,
pub relays: Vec<String>,
pub channels: Vec<InviteChannel>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_attestation: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<super::CommunityImage>,
}
const MAX_INVITE_CHANNELS: usize = 256;
impl CommunityInvite {
pub fn to_json(&self) -> Result<String, String> {
serde_json::to_string(self).map_err(|e| e.to_string())
}
pub fn from_json(json: &str) -> Result<Self, String> {
let mut inv: Self = serde_json::from_str(json).map_err(|e| e.to_string())?;
inv.relays = super::cap_relays(inv.relays);
Ok(inv)
}
pub fn validate(&self) -> Result<(), String> {
if self.channels.len() > MAX_INVITE_CHANNELS {
return Err(format!("invite declares too many channels ({})", self.channels.len()));
}
Ok(())
}
}
fn hex32(hex: &str) -> Result<[u8; 32], String> {
crate::simd::hex::hex_to_bytes_32_checked(hex)
.ok_or_else(|| format!("invalid or wrong-length 64-char hex ({} chars)", hex.len()))
}
pub fn build_invite(community: &Community) -> CommunityInvite {
CommunityInvite {
community_id: community.id.to_hex(),
name: community.name.clone(),
server_root_key: crate::simd::hex::bytes_to_hex_32(community.server_root_key.as_bytes()),
server_root_epoch: community.server_root_epoch.0,
relays: super::cap_relays(community.relays.clone()),
channels: community
.channels
.iter()
.map(|c| InviteChannel {
id: c.id.to_hex(),
key: crate::simd::hex::bytes_to_hex_32(c.key.as_bytes()),
epoch: c.epoch.0,
name: c.name.clone(),
})
.collect(),
owner_attestation: community.owner_attestation.clone(),
icon: community.icon.clone(),
}
}
pub fn accept_invite(invite: &CommunityInvite) -> Result<Community, String> {
invite.validate()?;
let id = CommunityId(hex32(&invite.community_id)?);
let server_root_key = ServerRootKey(hex32(&invite.server_root_key)?);
let owner_attestation = invite.owner_attestation.as_ref().and_then(|att| {
super::owner::verify_owner_attestation(att, &invite.community_id)
.map(|_| att.clone())
});
let mut channels = Vec::with_capacity(invite.channels.len());
for ic in &invite.channels {
channels.push(Channel {
id: ChannelId(hex32(&ic.id)?),
key: ChannelKey(hex32(&ic.key)?),
epoch: Epoch(ic.epoch),
name: ic.name.clone(),
banned: Vec::new(),
protected: Vec::new(), roster: Default::default(),
epoch_keys: Vec::new(),
dissolved: false,
});
}
Ok(Community {
id,
server_root_key,
server_root_epoch: Epoch(invite.server_root_epoch),
name: invite.name.clone(),
description: None,
icon: invite.icon.clone(),
banner: None,
relays: super::cap_relays(invite.relays.clone()),
channels,
owner_attestation,
dissolved: false,
})
}
pub fn build_invite_rumor(community: &Community, my_pubkey: PublicKey) -> Result<UnsignedEvent, String> {
let json = build_invite(community).to_json()?;
Ok(EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE), json).build(my_pubkey))
}
pub fn parse_invite_rumor(kind: Kind, content: &str) -> Option<CommunityInvite> {
if kind != Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE) {
return None;
}
CommunityInvite::from_json(content).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::community::envelope::{open_message, seal_message};
use nostr_sdk::prelude::Keys;
#[test]
fn relay_cap_truncates_dedups_and_holds_at_every_boundary() {
let many: Vec<String> = vec![
"wss://a".into(), "wss://b".into(), "wss://a".into(), "wss://c".into(),
"wss://d".into(), "wss://e".into(), "wss://f".into(),
];
let capped = super::super::cap_relays(many.clone());
assert_eq!(capped, vec!["wss://a", "wss://b", "wss://c", "wss://d", "wss://e"]);
let owner = Community::create("HQ", "general", many.clone());
assert!(owner.relays.len() <= super::super::MAX_COMMUNITY_RELAYS);
let mut bundle = build_invite(&owner);
bundle.relays = many; let json = bundle.to_json().unwrap();
let parsed = CommunityInvite::from_json(&json).unwrap();
assert!(parsed.relays.len() <= super::super::MAX_COMMUNITY_RELAYS);
let member = accept_invite(&parsed).unwrap();
assert!(member.relays.len() <= super::super::MAX_COMMUNITY_RELAYS);
}
#[test]
fn invite_round_trips_to_member_view() {
let owner = Community::create("HQ", "general", vec!["wss://r1".into(), "wss://r2".into()]);
let json = build_invite(&owner).to_json().unwrap();
let invite = CommunityInvite::from_json(&json).unwrap();
let member = accept_invite(&invite).unwrap();
assert_eq!(member.id, owner.id);
assert_eq!(member.name, "HQ");
assert_eq!(member.relays, owner.relays);
assert_eq!(member.server_root_key.as_bytes(), owner.server_root_key.as_bytes());
assert_eq!(member.channels.len(), 1);
assert_eq!(member.channels[0].id, owner.channels[0].id);
assert_eq!(member.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
assert_eq!(member.channels[0].name, "general");
}
#[test]
fn invited_member_can_read_owner_messages() {
let owner = Community::create("HQ", "general", vec![]);
let owner_author = Keys::generate();
let chan = &owner.channels[0];
let sealed =
seal_message(&owner_author, &chan.key, &chan.id, chan.epoch, "welcome!", 1).unwrap();
let invite = CommunityInvite::from_json(&build_invite(&owner).to_json().unwrap()).unwrap();
let member = accept_invite(&invite).unwrap();
let mc = &member.channels[0];
let opened = open_message(&sealed, &mc.key, &mc.id, mc.epoch).unwrap();
assert_eq!(opened.content, "welcome!");
assert_eq!(opened.author, owner_author.public_key());
}
#[test]
fn malformed_invite_errors() {
let owner = Community::create("HQ", "general", vec![]);
let mut invite = build_invite(&owner);
invite.server_root_key = "zz".into(); assert!(accept_invite(&invite).is_err());
}
#[test]
fn accept_rejects_a_bundle_exceeding_the_caps() {
let owner = Community::create("HQ", "general", vec![]);
let mut over_relays = build_invite(&owner);
over_relays.relays = (0..100).map(|i| format!("wss://r{i}")).collect();
let member = accept_invite(&over_relays).expect("over-relay bundle truncates, not rejects");
assert!(member.relays.len() <= super::super::MAX_COMMUNITY_RELAYS, "relays truncated to cap");
let mut over_channels = build_invite(&owner);
over_channels.channels = (0..500)
.map(|i| InviteChannel { id: "aa".repeat(32), key: "bb".repeat(32), epoch: 0, name: format!("c{i}") })
.collect();
assert!(accept_invite(&over_channels).is_err(), "too many channels → rejected");
}
#[test]
fn accept_rejects_malformed_community_id() {
let owner = Community::create("HQ", "general", vec![]);
let mut bad = build_invite(&owner);
bad.community_id = "not-64-hex".into();
assert!(accept_invite(&bad).is_err());
}
#[test]
fn accept_drops_a_bogus_owner_attestation_but_still_joins() {
let owner = Community::create("HQ", "general", vec![]);
let mut inv = build_invite(&owner);
inv.owner_attestation = Some("not even an event".to_string());
let member = accept_invite(&inv).unwrap();
assert!(member.owner_attestation.is_none(), "an unverifiable attestation is dropped, not trusted");
}
#[test]
fn accept_an_empty_channel_bundle_is_graceful() {
let owner = Community::create("HQ", "general", vec![]);
let mut inv = build_invite(&owner);
inv.channels.clear();
let member = accept_invite(&inv).unwrap();
assert!(member.channels.is_empty(), "a 0-channel bundle accepts without panic");
}
#[test]
fn bundle_carries_read_keys() {
let owner = Community::create("HQ", "general", vec![]);
let json = build_invite(&owner).to_json().unwrap();
assert!(json.contains(&crate::simd::hex::bytes_to_hex_32(owner.server_root_key.as_bytes())));
assert!(json.contains(&crate::simd::hex::bytes_to_hex_32(owner.channels[0].key.as_bytes())));
}
#[test]
fn invite_rumor_round_trips() {
let owner = Community::create("HQ", "general", vec!["wss://r1".into()]);
let author = Keys::generate();
let rumor = build_invite_rumor(&owner, author.public_key()).unwrap();
assert_eq!(rumor.kind, Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE));
let parsed = parse_invite_rumor(rumor.kind, &rumor.content).expect("parses");
let member = accept_invite(&parsed).unwrap();
assert_eq!(member.id, owner.id);
assert_eq!(member.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
}
#[test]
fn parse_invite_rumor_rejects_wrong_kind() {
let owner = Community::create("HQ", "general", vec![]);
let json = build_invite(&owner).to_json().unwrap();
assert!(parse_invite_rumor(Kind::Custom(14), &json).is_none());
assert!(parse_invite_rumor(Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE), "not json").is_none());
}
#[tokio::test]
async fn full_carrier_chain_invitee_reads_owner_message() {
use crate::community::transport::memory::MemoryRelay;
use crate::community::send::{fetch_channel_messages, publish_message};
let owner = Community::create("HQ", "general", vec!["r1".into()]);
let author = Keys::generate();
let rumor = build_invite_rumor(&owner, author.public_key()).unwrap();
let invite = parse_invite_rumor(rumor.kind, &rumor.content).expect("invite");
let member = accept_invite(&invite).unwrap();
let relay = MemoryRelay::new();
let owner_author = Keys::generate();
publish_message(&relay, &owner, &owner.channels[0], &owner_author, "welcome aboard", 1)
.await
.unwrap();
let msgs = fetch_channel_messages(&relay, &member, &member.channels[0]).await.unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].content, "welcome aboard");
assert_eq!(msgs[0].author, owner_author.public_key());
}
}