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 pub relays: Vec<String>,
48 pub channels: Vec<InviteChannel>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub owner_attestation: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub icon: Option<super::CommunityImage>,
59}
60
61const MAX_INVITE_CHANNELS: usize = 256;
66
67impl CommunityInvite {
68 pub fn to_json(&self) -> Result<String, String> {
69 serde_json::to_string(self).map_err(|e| e.to_string())
70 }
71 pub fn from_json(json: &str) -> Result<Self, String> {
72 let mut inv: Self = serde_json::from_str(json).map_err(|e| e.to_string())?;
73 inv.relays = super::cap_relays(inv.relays);
76 Ok(inv)
77 }
78
79 pub fn validate(&self) -> Result<(), String> {
83 if self.channels.len() > MAX_INVITE_CHANNELS {
84 return Err(format!("invite declares too many channels ({})", self.channels.len()));
85 }
86 Ok(())
87 }
88}
89
90fn hex32(hex: &str) -> Result<[u8; 32], String> {
93 crate::simd::hex::hex_to_bytes_32_checked(hex)
94 .ok_or_else(|| format!("invalid or wrong-length 64-char hex ({} chars)", hex.len()))
95}
96
97pub fn build_invite(community: &Community) -> CommunityInvite {
102 CommunityInvite {
103 community_id: community.id.to_hex(),
104 name: community.name.clone(),
105 server_root_key: crate::simd::hex::bytes_to_hex_32(community.server_root_key.as_bytes()),
106 server_root_epoch: community.server_root_epoch.0,
107 relays: super::cap_relays(community.relays.clone()),
108 channels: community
109 .channels
110 .iter()
111 .map(|c| InviteChannel {
112 id: c.id.to_hex(),
113 key: crate::simd::hex::bytes_to_hex_32(c.key.as_bytes()),
114 epoch: c.epoch.0,
115 name: c.name.clone(),
116 })
117 .collect(),
118 owner_attestation: community.owner_attestation.clone(),
119 icon: community.icon.clone(),
120 }
121}
122
123pub fn accept_invite(invite: &CommunityInvite) -> Result<Community, String> {
127 invite.validate()?;
128 let id = CommunityId(hex32(&invite.community_id)?);
129 let server_root_key = ServerRootKey(hex32(&invite.server_root_key)?);
130
131 let owner_attestation = invite.owner_attestation.as_ref().and_then(|att| {
134 super::owner::verify_owner_attestation(att, &invite.community_id)
135 .map(|_| att.clone())
136 });
137
138 let mut channels = Vec::with_capacity(invite.channels.len());
139 for ic in &invite.channels {
140 channels.push(Channel {
141 id: ChannelId(hex32(&ic.id)?),
142 key: ChannelKey(hex32(&ic.key)?),
143 epoch: Epoch(ic.epoch),
144 name: ic.name.clone(),
145 banned: Vec::new(),
147 protected: Vec::new(), roster: Default::default(),
148 epoch_keys: Vec::new(),
150 dissolved: false,
151 });
152 }
153
154 Ok(Community {
155 id,
156 server_root_key,
157 server_root_epoch: Epoch(invite.server_root_epoch),
158 name: invite.name.clone(),
159 description: None,
162 icon: invite.icon.clone(),
163 banner: None,
164 relays: super::cap_relays(invite.relays.clone()),
165 channels,
166 owner_attestation,
167 dissolved: false,
169 })
170}
171
172pub fn build_invite_rumor(
179 community: &Community,
180 my_pubkey: PublicKey,
181 now_secs: u64,
182) -> Result<UnsignedEvent, String> {
183 let json = build_invite(community).to_json()?;
184 Ok(
185 EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE), json)
186 .tag(nostr_sdk::prelude::Tag::expiration(
187 nostr_sdk::prelude::Timestamp::from_secs(now_secs + DIRECT_INVITE_EXPIRY_SECS),
188 ))
189 .finalize_unsigned_with_id(my_pubkey),
190 )
191}
192
193pub const DIRECT_INVITE_EXPIRY_SECS: u64 = 24 * 60 * 60;
200
201pub fn expiration_secs(tags: &nostr_sdk::prelude::Tags) -> Option<u64> {
204 tags.iter()
205 .find(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false))
206 .and_then(|t| t.as_slice().get(1))
207 .and_then(|v| v.parse::<u64>().ok())
208}
209
210pub fn parse_invite_rumor(kind: Kind, content: &str) -> Option<CommunityInvite> {
214 if kind != Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE) {
215 return None;
216 }
217 CommunityInvite::from_json(content).ok()
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::community::envelope::{open_message, seal_message};
224 use nostr_sdk::prelude::Keys;
225
226 #[test]
227 fn relay_cap_truncates_dedups_and_holds_at_every_boundary() {
228 let many: Vec<String> = vec![
230 "wss://a".into(), "wss://b".into(), "wss://a".into(), "wss://c".into(),
231 "wss://d".into(), "wss://e".into(), "wss://f".into(),
232 ];
233 let capped = super::super::cap_relays(many.clone());
234 assert_eq!(capped, vec!["wss://a", "wss://b", "wss://c", "wss://d", "wss://e"]);
235
236 let owner = Community::create("HQ", "general", many.clone());
238 assert!(owner.relays.len() <= super::super::MAX_COMMUNITY_RELAYS);
239
240 let mut bundle = build_invite(&owner);
243 bundle.relays = many; let json = bundle.to_json().unwrap();
245 let parsed = CommunityInvite::from_json(&json).unwrap();
246 assert!(parsed.relays.len() <= super::super::MAX_COMMUNITY_RELAYS);
247 let member = accept_invite(&parsed).unwrap();
248 assert!(member.relays.len() <= super::super::MAX_COMMUNITY_RELAYS);
249 }
250
251 #[test]
255 fn invite_rumor_declares_a_24h_expiry() {
256 let owner = Community::create("HQ", "general", vec!["wss://r1".into()]);
257 let author = Keys::generate();
258 let now = 1_800_000_000u64;
259 let rumor = build_invite_rumor(&owner, author.public_key(), now).unwrap();
260
261 let exp = expiration_secs(&rumor.tags).expect("rumor carries a NIP-40 expiration tag");
262 assert_eq!(exp, now + 86_400, "24h after the send time");
263 assert_eq!(DIRECT_INVITE_EXPIRY_SECS, 86_400);
264 }
265
266 #[test]
270 fn expiration_secs_is_total_over_hostile_tags() {
271 use nostr_sdk::prelude::{Tag, Timestamp};
272 let author = Keys::generate();
273 let mk = |tags: Vec<Tag>| {
274 EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE), "{}")
275 .tags(tags)
276 .finalize_unsigned_with_id(author.public_key())
277 };
278
279 assert_eq!(expiration_secs(&mk(vec![]).tags), None, "no tag = no deadline");
280 assert_eq!(
282 expiration_secs(&mk(vec![Tag::custom("expiration", ["soon"])]).tags),
283 None,
284 );
285 assert_eq!(
287 expiration_secs(&mk(vec![Tag::custom("expiration", Vec::<String>::new())]).tags),
288 None,
289 );
290 let tags = vec![
292 Tag::public_key(author.public_key()),
293 Tag::expiration(Timestamp::from_secs(1_800_086_400)),
294 ];
295 assert_eq!(expiration_secs(&mk(tags).tags), Some(1_800_086_400));
296 }
297
298 #[test]
299 fn epoch_zero_channel_serializes_the_field_explicitly() {
300 let owner = Community::create("HQ", "general", vec!["wss://r1".into()]);
304 let bundle = build_invite(&owner);
305 assert_eq!(bundle.channels[0].epoch, 0);
306 let json = bundle.to_json().unwrap();
307 assert!(json.contains("\"epoch\":0"), "epoch must never be omitted: {json}");
308 }
309
310 #[test]
311 fn invite_round_trips_to_member_view() {
312 let owner = Community::create("HQ", "general", vec!["wss://r1".into(), "wss://r2".into()]);
313 let json = build_invite(&owner).to_json().unwrap();
314 let invite = CommunityInvite::from_json(&json).unwrap();
315 let member = accept_invite(&invite).unwrap();
316
317 assert_eq!(member.id, owner.id);
319 assert_eq!(member.name, "HQ");
320 assert_eq!(member.relays, owner.relays);
321 assert_eq!(member.server_root_key.as_bytes(), owner.server_root_key.as_bytes());
322 assert_eq!(member.channels.len(), 1);
323 assert_eq!(member.channels[0].id, owner.channels[0].id);
324 assert_eq!(member.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
325 assert_eq!(member.channels[0].name, "general");
326 }
327
328 #[test]
329 fn invited_member_can_read_owner_messages() {
330 let owner = Community::create("HQ", "general", vec![]);
334 let owner_author = Keys::generate();
335 let chan = &owner.channels[0];
336 let sealed =
337 seal_message(&owner_author, &chan.key, &chan.id, chan.epoch, "welcome!", 1).unwrap();
338
339 let invite = CommunityInvite::from_json(&build_invite(&owner).to_json().unwrap()).unwrap();
340 let member = accept_invite(&invite).unwrap();
341 let mc = &member.channels[0];
342 let opened = open_message(&sealed, &mc.key, &mc.id, mc.epoch).unwrap();
343 assert_eq!(opened.content, "welcome!");
344 assert_eq!(opened.author, owner_author.public_key());
345 }
346
347 #[test]
348 fn malformed_invite_errors() {
349 let owner = Community::create("HQ", "general", vec![]);
350 let mut invite = build_invite(&owner);
351 invite.server_root_key = "zz".into(); assert!(accept_invite(&invite).is_err());
353 }
354
355 #[test]
356 fn accept_rejects_a_bundle_exceeding_the_caps() {
357 let owner = Community::create("HQ", "general", vec![]);
361 let mut over_relays = build_invite(&owner);
362 over_relays.relays = (0..100).map(|i| format!("wss://r{i}")).collect();
363 let member = accept_invite(&over_relays).expect("over-relay bundle truncates, not rejects");
364 assert!(member.relays.len() <= super::super::MAX_COMMUNITY_RELAYS, "relays truncated to cap");
365
366 let mut over_channels = build_invite(&owner);
367 over_channels.channels = (0..500)
368 .map(|i| InviteChannel { id: "aa".repeat(32), key: "bb".repeat(32), epoch: 0, name: format!("c{i}") })
369 .collect();
370 assert!(accept_invite(&over_channels).is_err(), "too many channels → rejected");
371 }
372
373 #[test]
374 fn accept_rejects_malformed_community_id() {
375 let owner = Community::create("HQ", "general", vec![]);
377 let mut bad = build_invite(&owner);
378 bad.community_id = "not-64-hex".into();
379 assert!(accept_invite(&bad).is_err());
380 }
381
382 #[test]
383 fn accept_drops_a_bogus_owner_attestation_but_still_joins() {
384 let owner = Community::create("HQ", "general", vec![]);
387 let mut inv = build_invite(&owner);
388 inv.owner_attestation = Some("not even an event".to_string());
389 let member = accept_invite(&inv).unwrap();
390 assert!(member.owner_attestation.is_none(), "an unverifiable attestation is dropped, not trusted");
391 }
392
393 #[test]
394 fn accept_an_empty_channel_bundle_is_graceful() {
395 let owner = Community::create("HQ", "general", vec![]);
396 let mut inv = build_invite(&owner);
397 inv.channels.clear();
398 let member = accept_invite(&inv).unwrap();
399 assert!(member.channels.is_empty(), "a 0-channel bundle accepts without panic");
400 }
401
402 #[test]
403 fn bundle_carries_read_keys() {
404 let owner = Community::create("HQ", "general", vec![]);
406 let json = build_invite(&owner).to_json().unwrap();
407 assert!(json.contains(&crate::simd::hex::bytes_to_hex_32(owner.server_root_key.as_bytes())));
408 assert!(json.contains(&crate::simd::hex::bytes_to_hex_32(owner.channels[0].key.as_bytes())));
409 }
410
411 #[test]
412 fn invite_rumor_round_trips() {
413 let owner = Community::create("HQ", "general", vec!["wss://r1".into()]);
414 let author = Keys::generate();
415 let rumor = build_invite_rumor(&owner, author.public_key(), 1_800_000_000).unwrap();
416
417 assert_eq!(rumor.kind, Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE));
418 let parsed = parse_invite_rumor(rumor.kind, &rumor.content).expect("parses");
419 let member = accept_invite(&parsed).unwrap();
420 assert_eq!(member.id, owner.id);
421 assert_eq!(member.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
422 }
423
424 #[test]
425 fn invite_rumor_stamps_real_send_time_not_backdated() {
426 let owner = Community::create("HQ", "general", vec![]);
431 let author = Keys::generate();
432 let now = nostr_sdk::prelude::Timestamp::now().as_secs();
433 let rumor = build_invite_rumor(&owner, author.public_key(), 1_800_000_000).unwrap();
434 let stamped = rumor.created_at.as_secs();
435 assert!(
436 stamped + 5 >= now && stamped <= now + 5,
437 "rumor created_at {stamped} must track now {now}, not be backdated"
438 );
439 }
440
441 #[test]
442 fn parse_invite_rumor_rejects_wrong_kind() {
443 let owner = Community::create("HQ", "general", vec![]);
446 let json = build_invite(&owner).to_json().unwrap();
447 assert!(parse_invite_rumor(Kind::Custom(14), &json).is_none());
448 assert!(parse_invite_rumor(Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE), "not json").is_none());
450 }
451
452 #[tokio::test]
453 async fn full_carrier_chain_invitee_reads_owner_message() {
454 use crate::community::transport::memory::MemoryRelay;
458 use crate::community::send::{fetch_channel_messages, publish_message};
459
460 let owner = Community::create("HQ", "general", vec!["r1".into()]);
461 let author = Keys::generate();
462 let rumor = build_invite_rumor(&owner, author.public_key(), 1_800_000_000).unwrap();
463
464 let invite = parse_invite_rumor(rumor.kind, &rumor.content).expect("invite");
466 let member = accept_invite(&invite).unwrap();
467
468 let relay = MemoryRelay::new();
470 let owner_author = Keys::generate();
471 publish_message(&relay, &owner, &owner.channels[0], &owner_author, "welcome aboard", 1)
472 .await
473 .unwrap();
474
475 let msgs = fetch_channel_messages(&relay, &member, &member.channels[0]).await.unwrap();
476 assert_eq!(msgs.len(), 1);
477 assert_eq!(msgs[0].content, "welcome aboard");
478 assert_eq!(msgs[0].author, owner_author.public_key());
479 }
480}