1use crate::event_ext::FinalizeUnsignedWithId;
13use serde::{Deserialize, Serialize};
14
15use super::{Channel, ChannelId, ChannelKey, Community, CommunityId, Epoch, ServerRootKey};
16use crate::stored_event::event_kind;
17use nostr_sdk::prelude::{EventBuilder, Kind, PublicKey, UnsignedEvent};
18
19fn is_zero_u64(n: &u64) -> bool {
22 *n == 0
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct InviteChannel {
28 pub id: String,
29 pub key: String,
30 #[serde(default)]
33 pub epoch: u64,
34 pub name: String,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct CommunityInvite {
40 pub community_id: String,
41 pub name: String,
42 pub server_root_key: String,
43 #[serde(default, skip_serializing_if = "is_zero_u64")]
46 pub server_root_epoch: u64,
47 #[serde(default)]
52 pub relays: Vec<String>,
53 #[serde(default)]
54 pub channels: Vec<InviteChannel>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub owner_attestation: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub icon: Option<super::CommunityImage>,
65}
66
67const MAX_INVITE_CHANNELS: usize = 256;
72
73impl CommunityInvite {
74 pub fn to_json(&self) -> Result<String, String> {
75 serde_json::to_string(self).map_err(|e| e.to_string())
76 }
77 pub fn from_json(json: &str) -> Result<Self, String> {
78 let mut inv: Self = serde_json::from_str(json).map_err(|e| e.to_string())?;
79 inv.relays = super::cap_relays(inv.relays);
82 Ok(inv)
83 }
84
85 pub fn validate(&self) -> Result<(), String> {
89 if self.channels.len() > MAX_INVITE_CHANNELS {
90 return Err(format!("invite declares too many channels ({})", self.channels.len()));
91 }
92 Ok(())
93 }
94}
95
96fn hex32(hex: &str) -> Result<[u8; 32], String> {
99 crate::simd::hex::hex_to_bytes_32_checked(hex)
100 .ok_or_else(|| format!("invalid or wrong-length 64-char hex ({} chars)", hex.len()))
101}
102
103pub fn build_invite(community: &Community) -> CommunityInvite {
108 CommunityInvite {
109 community_id: community.id.to_hex(),
110 name: community.name.clone(),
111 server_root_key: crate::simd::hex::bytes_to_hex_32(community.server_root_key.as_bytes()),
112 server_root_epoch: community.server_root_epoch.0,
113 relays: super::cap_relays(community.relays.clone()),
114 channels: community
115 .channels
116 .iter()
117 .map(|c| InviteChannel {
118 id: c.id.to_hex(),
119 key: crate::simd::hex::bytes_to_hex_32(c.key.as_bytes()),
120 epoch: c.epoch.0,
121 name: c.name.clone(),
122 })
123 .collect(),
124 owner_attestation: community.owner_attestation.clone(),
125 icon: community.icon.clone(),
126 }
127}
128
129pub fn accept_invite(invite: &CommunityInvite) -> Result<Community, String> {
133 invite.validate()?;
134 let id = CommunityId(hex32(&invite.community_id)?);
135 let server_root_key = ServerRootKey(hex32(&invite.server_root_key)?);
136
137 let owner_attestation = invite.owner_attestation.as_ref().and_then(|att| {
140 super::owner::verify_owner_attestation(att, &invite.community_id)
141 .map(|_| att.clone())
142 });
143
144 let mut channels = Vec::with_capacity(invite.channels.len());
145 for ic in &invite.channels {
146 channels.push(Channel {
147 id: ChannelId(hex32(&ic.id)?),
148 key: ChannelKey(hex32(&ic.key)?),
149 epoch: Epoch(ic.epoch),
150 name: ic.name.clone(),
151 banned: Vec::new(),
153 protected: Vec::new(), roster: Default::default(),
154 epoch_keys: Vec::new(),
156 dissolved: false,
157 });
158 }
159
160 Ok(Community {
161 id,
162 server_root_key,
163 server_root_epoch: Epoch(invite.server_root_epoch),
164 name: invite.name.clone(),
165 description: None,
168 icon: invite.icon.clone(),
169 banner: None,
170 relays: super::cap_relays(invite.relays.clone()),
171 channels,
172 owner_attestation,
173 dissolved: false,
175 })
176}
177
178pub fn build_invite_rumor(
185 community: &Community,
186 my_pubkey: PublicKey,
187 now_secs: u64,
188) -> Result<UnsignedEvent, String> {
189 let json = build_invite(community).to_json()?;
190 Ok(
191 EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE), json)
192 .tag(nostr_sdk::prelude::Tag::expiration(
193 nostr_sdk::prelude::Timestamp::from_secs(now_secs + DIRECT_INVITE_EXPIRY_SECS),
194 ))
195 .finalize_unsigned_with_id(my_pubkey),
196 )
197}
198
199pub const DIRECT_INVITE_EXPIRY_SECS: u64 = 24 * 60 * 60;
206
207pub fn expiration_secs(tags: &nostr_sdk::prelude::Tags) -> Option<u64> {
210 tags.iter()
211 .find(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false))
212 .and_then(|t| t.as_slice().get(1))
213 .and_then(|v| v.parse::<u64>().ok())
214}
215
216pub fn parse_invite_rumor(kind: Kind, content: &str) -> Option<CommunityInvite> {
220 if kind != Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE) {
221 return None;
222 }
223 CommunityInvite::from_json(content).ok()
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::community::envelope::{open_message, seal_message};
230 use nostr_sdk::prelude::Keys;
231
232 #[test]
233 fn relay_cap_truncates_dedups_and_holds_at_every_boundary() {
234 let many: Vec<String> = vec![
236 "wss://a".into(), "wss://b".into(), "wss://a".into(), "wss://c".into(),
237 "wss://d".into(), "wss://e".into(), "wss://f".into(),
238 ];
239 let capped = super::super::cap_relays(many.clone());
240 assert_eq!(capped, vec!["wss://a", "wss://b", "wss://c", "wss://d", "wss://e"]);
241
242 let owner = Community::create("HQ", "general", many.clone());
244 assert!(owner.relays.len() <= super::super::MAX_COMMUNITY_RELAYS);
245
246 let mut bundle = build_invite(&owner);
249 bundle.relays = many; let json = bundle.to_json().unwrap();
251 let parsed = CommunityInvite::from_json(&json).unwrap();
252 assert!(parsed.relays.len() <= super::super::MAX_COMMUNITY_RELAYS);
253 let member = accept_invite(&parsed).unwrap();
254 assert!(member.relays.len() <= super::super::MAX_COMMUNITY_RELAYS);
255 }
256
257 #[test]
261 fn invite_rumor_declares_a_24h_expiry() {
262 let owner = Community::create("HQ", "general", vec!["wss://r1".into()]);
263 let author = Keys::generate();
264 let now = 1_800_000_000u64;
265 let rumor = build_invite_rumor(&owner, author.public_key(), now).unwrap();
266
267 let exp = expiration_secs(&rumor.tags).expect("rumor carries a NIP-40 expiration tag");
268 assert_eq!(exp, now + 86_400, "24h after the send time");
269 assert_eq!(DIRECT_INVITE_EXPIRY_SECS, 86_400);
270 }
271
272 #[test]
276 fn expiration_secs_is_total_over_hostile_tags() {
277 use nostr_sdk::prelude::{Tag, Timestamp};
278 let author = Keys::generate();
279 let mk = |tags: Vec<Tag>| {
280 EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE), "{}")
281 .tags(tags)
282 .finalize_unsigned_with_id(author.public_key())
283 };
284
285 assert_eq!(expiration_secs(&mk(vec![]).tags), None, "no tag = no deadline");
286 assert_eq!(
288 expiration_secs(&mk(vec![Tag::custom("expiration", ["soon"])]).tags),
289 None,
290 );
291 assert_eq!(
293 expiration_secs(&mk(vec![Tag::custom("expiration", Vec::<String>::new())]).tags),
294 None,
295 );
296 let tags = vec![
298 Tag::public_key(author.public_key()),
299 Tag::expiration(Timestamp::from_secs(1_800_086_400)),
300 ];
301 assert_eq!(expiration_secs(&mk(tags).tags), Some(1_800_086_400));
302 }
303
304 #[test]
305 fn epoch_zero_channel_serializes_the_field_explicitly() {
306 let owner = Community::create("HQ", "general", vec!["wss://r1".into()]);
310 let bundle = build_invite(&owner);
311 assert_eq!(bundle.channels[0].epoch, 0);
312 let json = bundle.to_json().unwrap();
313 assert!(json.contains("\"epoch\":0"), "epoch must never be omitted: {json}");
314 }
315
316 #[test]
317 fn invite_round_trips_to_member_view() {
318 let owner = Community::create("HQ", "general", vec!["wss://r1".into(), "wss://r2".into()]);
319 let json = build_invite(&owner).to_json().unwrap();
320 let invite = CommunityInvite::from_json(&json).unwrap();
321 let member = accept_invite(&invite).unwrap();
322
323 assert_eq!(member.id, owner.id);
325 assert_eq!(member.name, "HQ");
326 assert_eq!(member.relays, owner.relays);
327 assert_eq!(member.server_root_key.as_bytes(), owner.server_root_key.as_bytes());
328 assert_eq!(member.channels.len(), 1);
329 assert_eq!(member.channels[0].id, owner.channels[0].id);
330 assert_eq!(member.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
331 assert_eq!(member.channels[0].name, "general");
332 }
333
334 #[test]
335 fn invited_member_can_read_owner_messages() {
336 let owner = Community::create("HQ", "general", vec![]);
340 let owner_author = Keys::generate();
341 let chan = &owner.channels[0];
342 let sealed =
343 seal_message(&owner_author, &chan.key, &chan.id, chan.epoch, "welcome!", 1).unwrap();
344
345 let invite = CommunityInvite::from_json(&build_invite(&owner).to_json().unwrap()).unwrap();
346 let member = accept_invite(&invite).unwrap();
347 let mc = &member.channels[0];
348 let opened = open_message(&sealed, &mc.key, &mc.id, mc.epoch).unwrap();
349 assert_eq!(opened.content, "welcome!");
350 assert_eq!(opened.author, owner_author.public_key());
351 }
352
353 #[test]
354 fn malformed_invite_errors() {
355 let owner = Community::create("HQ", "general", vec![]);
356 let mut invite = build_invite(&owner);
357 invite.server_root_key = "zz".into(); assert!(accept_invite(&invite).is_err());
359 }
360
361 #[test]
362 fn accept_rejects_a_bundle_exceeding_the_caps() {
363 let owner = Community::create("HQ", "general", vec![]);
367 let mut over_relays = build_invite(&owner);
368 over_relays.relays = (0..100).map(|i| format!("wss://r{i}")).collect();
369 let member = accept_invite(&over_relays).expect("over-relay bundle truncates, not rejects");
370 assert!(member.relays.len() <= super::super::MAX_COMMUNITY_RELAYS, "relays truncated to cap");
371
372 let mut over_channels = build_invite(&owner);
373 over_channels.channels = (0..500)
374 .map(|i| InviteChannel { id: "aa".repeat(32), key: "bb".repeat(32), epoch: 0, name: format!("c{i}") })
375 .collect();
376 assert!(accept_invite(&over_channels).is_err(), "too many channels → rejected");
377 }
378
379 #[test]
380 fn accept_rejects_malformed_community_id() {
381 let owner = Community::create("HQ", "general", vec![]);
383 let mut bad = build_invite(&owner);
384 bad.community_id = "not-64-hex".into();
385 assert!(accept_invite(&bad).is_err());
386 }
387
388 #[test]
389 fn accept_drops_a_bogus_owner_attestation_but_still_joins() {
390 let owner = Community::create("HQ", "general", vec![]);
393 let mut inv = build_invite(&owner);
394 inv.owner_attestation = Some("not even an event".to_string());
395 let member = accept_invite(&inv).unwrap();
396 assert!(member.owner_attestation.is_none(), "an unverifiable attestation is dropped, not trusted");
397 }
398
399 #[test]
400 fn accept_an_empty_channel_bundle_is_graceful() {
401 let owner = Community::create("HQ", "general", vec![]);
402 let mut inv = build_invite(&owner);
403 inv.channels.clear();
404 let member = accept_invite(&inv).unwrap();
405 assert!(member.channels.is_empty(), "a 0-channel bundle accepts without panic");
406 }
407
408 #[test]
409 fn bundle_carries_read_keys() {
410 let owner = Community::create("HQ", "general", vec![]);
412 let json = build_invite(&owner).to_json().unwrap();
413 assert!(json.contains(&crate::simd::hex::bytes_to_hex_32(owner.server_root_key.as_bytes())));
414 assert!(json.contains(&crate::simd::hex::bytes_to_hex_32(owner.channels[0].key.as_bytes())));
415 }
416
417 #[test]
418 fn invite_rumor_round_trips() {
419 let owner = Community::create("HQ", "general", vec!["wss://r1".into()]);
420 let author = Keys::generate();
421 let rumor = build_invite_rumor(&owner, author.public_key(), 1_800_000_000).unwrap();
422
423 assert_eq!(rumor.kind, Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE));
424 let parsed = parse_invite_rumor(rumor.kind, &rumor.content).expect("parses");
425 let member = accept_invite(&parsed).unwrap();
426 assert_eq!(member.id, owner.id);
427 assert_eq!(member.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
428 }
429
430 #[test]
431 fn invite_rumor_stamps_real_send_time_not_backdated() {
432 let owner = Community::create("HQ", "general", vec![]);
437 let author = Keys::generate();
438 let now = nostr_sdk::prelude::Timestamp::now().as_secs();
439 let rumor = build_invite_rumor(&owner, author.public_key(), 1_800_000_000).unwrap();
440 let stamped = rumor.created_at.as_secs();
441 assert!(
442 stamped + 5 >= now && stamped <= now + 5,
443 "rumor created_at {stamped} must track now {now}, not be backdated"
444 );
445 }
446
447 #[test]
448 fn parse_invite_rumor_rejects_wrong_kind() {
449 let owner = Community::create("HQ", "general", vec![]);
452 let json = build_invite(&owner).to_json().unwrap();
453 assert!(parse_invite_rumor(Kind::Custom(14), &json).is_none());
454 assert!(parse_invite_rumor(Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE), "not json").is_none());
456 }
457
458 #[tokio::test]
459 async fn full_carrier_chain_invitee_reads_owner_message() {
460 use crate::community::transport::memory::MemoryRelay;
464 use crate::community::send::{fetch_channel_messages, publish_message};
465
466 let owner = Community::create("HQ", "general", vec!["r1".into()]);
467 let author = Keys::generate();
468 let rumor = build_invite_rumor(&owner, author.public_key(), 1_800_000_000).unwrap();
469
470 let invite = parse_invite_rumor(rumor.kind, &rumor.content).expect("invite");
472 let member = accept_invite(&invite).unwrap();
473
474 let relay = MemoryRelay::new();
476 let owner_author = Keys::generate();
477 publish_message(&relay, &owner, &owner.channels[0], &owner_author, "welcome aboard", 1)
478 .await
479 .unwrap();
480
481 let msgs = fetch_channel_messages(&relay, &member, &member.channels[0]).await.unwrap();
482 assert_eq!(msgs.len(), 1);
483 assert_eq!(msgs[0].content, "welcome aboard");
484 assert_eq!(msgs[0].author, owner_author.public_key());
485 }
486}