1use crate::event_ext::FinalizeUnsignedWithId;
23use nostr_sdk::prelude::FinalizeEvent;
24use nostr_sdk::prelude::nip44::{
25 self,
26 v2::{decrypt_to_bytes, ConversationKey},
27};
28use nostr_sdk::prelude::{
29 Event, EventBuilder, FromBech32, Keys, Kind, PublicKey, Tag, Timestamp, ToBech32, UnsignedEvent,
30};
31use serde::{Deserialize, Serialize};
32
33use super::super::{cap_relays, CommunityId};
34use super::control::ImageRef;
35use super::derive::{verify_community_id, TOKEN_LEN};
36use super::{kind, vsk};
37
38pub const MAX_BUNDLE_CHANNELS: usize = 256;
42
43pub const MAX_BUNDLE_EPOCH: u64 = 1 << 40;
48
49pub const FRAGMENT_VERSION: u8 = 4;
52
53pub const MAX_BOOTSTRAP_RELAYS: usize = 3;
56
57const FLAG_STOCK_SET: u8 = 0x01;
59
60const RELAY_DICT: [&str; 4] = [
65 "wss://jskitty.com/nostr", "wss://asia.vectorapp.io/nostr", "wss://relay.ditto.pub", "wss://relay.dreamith.to", ];
70
71#[derive(Debug)]
75pub enum InviteError {
76 Json(String),
77 BadHex(&'static str),
79 TooManyChannels(usize),
81 OwnerMismatch,
83 BadFragment(&'static str),
85 BadVersion(u8),
87 BadLink(&'static str),
89 BadEvent(&'static str),
92 Crypto(String),
94}
95
96impl std::fmt::Display for InviteError {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 match self {
99 InviteError::Json(e) => write!(f, "json: {e}"),
100 InviteError::BadHex(field) => write!(f, "field {field} is not 32-byte hex"),
101 InviteError::TooManyChannels(n) => write!(f, "bundle carries {n} channels (cap {MAX_BUNDLE_CHANNELS})"),
102 InviteError::OwnerMismatch => write!(f, "bundle owner does not reproduce its community_id"),
103 InviteError::BadFragment(why) => write!(f, "bad invite fragment: {why}"),
104 InviteError::BadVersion(v) => write!(f, "unsupported invite fragment version {v}"),
105 InviteError::BadLink(why) => write!(f, "bad invite link: {why}"),
106 InviteError::BadEvent(why) => write!(f, "bad invite bundle event: {why}"),
107 InviteError::Crypto(e) => write!(f, "crypto: {e}"),
108 }
109 }
110}
111
112impl std::error::Error for InviteError {}
113
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct ChannelGrant {
120 pub id: String,
122 pub key: String,
124 pub epoch: u64,
125 pub name: String,
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct CommunityInvite {
132 pub community_id: String,
134 pub owner: String,
136 pub owner_salt: String,
138 pub community_root: String,
140 pub root_epoch: u64,
141 #[serde(skip_serializing_if = "Option::is_none")]
148 pub control_pk: Option<String>,
149 #[serde(default)]
153 pub channels: Vec<ChannelGrant>,
154 #[serde(default)]
155 pub relays: Vec<String>,
156 pub name: String,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub icon: Option<ImageRef>,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub expires_at: Option<u64>,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub creator_npub: Option<String>,
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub label: Option<String>,
168 #[serde(flatten)]
171 pub extra: serde_json::Map<String, serde_json::Value>,
172}
173
174impl CommunityInvite {
175 pub fn from_bundle_json(json: &str) -> Result<Self, InviteError> {
179 let mut bundle: CommunityInvite = serde_json::from_str(json).map_err(|e| InviteError::Json(e.to_string()))?;
180 if bundle.channels.len() > MAX_BUNDLE_CHANNELS {
181 return Err(InviteError::TooManyChannels(bundle.channels.len()));
182 }
183 bundle.relays = cap_relays(std::mem::take(&mut bundle.relays));
184 bundle.validate()?;
185 Ok(bundle)
186 }
187
188 pub fn validate(&self) -> Result<(), InviteError> {
192 if self.channels.len() > MAX_BUNDLE_CHANNELS {
193 return Err(InviteError::TooManyChannels(self.channels.len()));
194 }
195 if self.root_epoch > MAX_BUNDLE_EPOCH || self.channels.iter().any(|c| c.epoch > MAX_BUNDLE_EPOCH) {
200 return Err(InviteError::BadFragment("epoch out of range"));
201 }
202 let owner = hex32(&self.owner, "owner")?;
203 let salt = hex32(&self.owner_salt, "owner_salt")?;
204 let cid = hex32(&self.community_id, "community_id")?;
205 if !verify_community_id(&CommunityId(cid), &owner, &salt) {
206 return Err(InviteError::OwnerMismatch);
207 }
208 Ok(())
209 }
210
211 pub fn expired(&self, now_ms: u64) -> bool {
215 self.expires_at.is_some_and(|e| now_ms > e)
216 }
217}
218
219#[derive(Debug)]
223pub enum BundleState {
224 Live(Box<CommunityInvite>),
226 Revoked,
228}
229
230pub fn build_bundle_event(
235 link_signer: &Keys,
236 bundle: &CommunityInvite,
237 bundle_key: &[u8; 32],
238) -> Result<Event, InviteError> {
239 let json = serde_json::to_string(bundle).map_err(|e| InviteError::Json(e.to_string()))?;
240 let content = seal_bundle(bundle_key, &json)?;
241 EventBuilder::new(Kind::Custom(kind::INVITE_BUNDLE), content)
242 .tags([d_empty(), vsk_tag(vsk::INVITE_LIVE)])
243 .finalize(link_signer)
244 .map_err(|e| InviteError::Crypto(e.to_string()))
245}
246
247pub fn build_revocation(link_signer: &Keys) -> Result<Event, InviteError> {
251 EventBuilder::new(Kind::Custom(kind::INVITE_BUNDLE), "")
252 .tags([d_empty(), vsk_tag(vsk::INVITE_REVOKED)])
253 .finalize(link_signer)
254 .map_err(|e| InviteError::Crypto(e.to_string()))
255}
256
257pub fn parse_bundle_event(
264 event: &Event,
265 expected_signer: &PublicKey,
266 bundle_key: &[u8; 32],
267) -> Result<BundleState, InviteError> {
268 if event.kind.as_u16() != kind::INVITE_BUNDLE {
269 return Err(InviteError::BadEvent("wrong kind"));
270 }
271 if event.pubkey != *expected_signer {
272 return Err(InviteError::BadEvent("author is not the link signer"));
273 }
274 if !first_tag(event, "d").unwrap_or_default().is_empty() {
277 return Err(InviteError::BadEvent("bundle is not at the link's coordinate"));
278 }
279 event.verify().map_err(|_| InviteError::BadEvent("signature invalid"))?;
280
281 match first_tag(event, "vsk").as_deref() {
282 Some(v) if v == vsk::INVITE_REVOKED => return Ok(BundleState::Revoked),
283 Some(v) if v == vsk::INVITE_LIVE => {}
284 _ => return Err(InviteError::BadEvent("unknown or missing bundle marker")),
285 }
286
287 let json = open_bundle(bundle_key, &event.content)?;
288 Ok(BundleState::Live(Box::new(CommunityInvite::from_bundle_json(&json)?)))
289}
290
291const INVITE_PATH: &str = "/invite/";
294
295#[derive(Debug, Clone)]
297pub struct ParsedInviteLink {
298 pub link_signer: PublicKey,
300 pub token: [u8; TOKEN_LEN],
301 pub bootstrap_relays: Vec<String>,
302 pub naddr: String,
304}
305
306pub fn stock_relays() -> Vec<String> {
309 RELAY_DICT.iter().map(|s| s.to_string()).collect()
310}
311
312pub fn encode_fragment(token: &[u8; TOKEN_LEN], relays: &[String]) -> Result<String, InviteError> {
318 let is_stock = relays.len() == RELAY_DICT.len() && relays.iter().zip(RELAY_DICT.iter()).all(|(r, d)| r == d);
319
320 let mut bytes = Vec::with_capacity(2 + TOKEN_LEN + relays.len() * 8);
321 bytes.push(FRAGMENT_VERSION);
322 if is_stock {
323 bytes.push(FLAG_STOCK_SET);
324 } else {
325 bytes.push(0x00);
326 let bounded = &relays[..relays.len().min(MAX_BOOTSTRAP_RELAYS)];
327 bytes.push(bounded.len() as u8);
328 for relay in bounded {
329 if let Some(id) = dict_id(relay) {
330 bytes.push(id);
331 } else if let Some(host) = relay.strip_prefix("wss://") {
332 if host.len() > u8::MAX as usize {
333 return Err(InviteError::BadFragment("relay host too long"));
334 }
335 bytes.extend_from_slice(&[0x00, host.len() as u8]);
336 bytes.extend_from_slice(host.as_bytes());
337 } else {
338 if relay.len() > u8::MAX as usize {
339 return Err(InviteError::BadFragment("relay url too long"));
340 }
341 bytes.extend_from_slice(&[0xff, relay.len() as u8]);
342 bytes.extend_from_slice(relay.as_bytes());
343 }
344 }
345 }
346 bytes.extend_from_slice(token);
347 Ok(base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes))
348}
349
350pub fn decode_fragment(fragment: &str) -> Result<([u8; TOKEN_LEN], Vec<String>), InviteError> {
355 let bytes = base64_simd::URL_SAFE_NO_PAD
356 .decode_to_vec(fragment.trim().as_bytes())
357 .map_err(|_| InviteError::BadFragment("not base64url"))?;
358
359 if bytes.len() < 2 {
360 return Err(InviteError::BadFragment("truncated"));
361 }
362 let version = bytes[0];
363 if version != FRAGMENT_VERSION {
366 return Err(InviteError::BadVersion(version));
367 }
368 let flags = bytes[1];
369 let mut o = 2usize;
370
371 let mut relays = Vec::new();
372 if flags & FLAG_STOCK_SET != 0 {
373 relays = stock_relays();
374 } else {
375 let count = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))? as usize;
376 o += 1;
377 if count > MAX_BOOTSTRAP_RELAYS {
378 return Err(InviteError::BadFragment("too many bootstrap relays"));
379 }
380 for _ in 0..count {
381 let lead = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))?;
382 o += 1;
383 if (1..=254).contains(&lead) {
384 if let Some(url) = dict_url(lead) {
385 relays.push(url.to_string());
386 }
387 } else {
389 let len = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))? as usize;
390 o += 1;
391 let end = o.checked_add(len).ok_or(InviteError::BadFragment("truncated"))?;
392 let raw = bytes.get(o..end).ok_or(InviteError::BadFragment("truncated"))?;
393 let text = std::str::from_utf8(raw).map_err(|_| InviteError::BadFragment("relay not utf8"))?;
394 relays.push(if lead == 255 { text.to_string() } else { format!("wss://{text}") });
395 o = end;
396 }
397 }
398 }
399
400 let end = o.checked_add(TOKEN_LEN).ok_or(InviteError::BadFragment("truncated"))?;
401 let raw = bytes.get(o..end).ok_or(InviteError::BadFragment("truncated"))?;
402 let mut token = [0u8; TOKEN_LEN];
403 token.copy_from_slice(raw);
404 if end != bytes.len() {
405 return Err(InviteError::BadFragment("trailing bytes"));
406 }
407 Ok((token, relays))
408}
409
410pub fn bundle_naddr(link_signer: &PublicKey) -> Result<String, InviteError> {
414 let coord = nostr_sdk::prelude::nip01::Coordinate {
415 kind: Kind::Custom(kind::INVITE_BUNDLE),
416 public_key: *link_signer,
417 identifier: String::new(),
418 };
419 let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate { coordinate: coord, relays: Vec::new() };
420 nostr_sdk::prelude::nip19::Nip19::Coordinate(n19)
421 .to_bech32()
422 .map_err(|e| InviteError::Crypto(e.to_string()))
423}
424
425pub fn build_invite_url(
428 base: &str,
429 link_signer: &PublicKey,
430 token: &[u8; TOKEN_LEN],
431 relays: &[String],
432) -> Result<String, InviteError> {
433 let naddr = bundle_naddr(link_signer)?;
434 let fragment = encode_fragment(token, relays)?;
435 Ok(format!("{}{INVITE_PATH}{naddr}#{fragment}", base.trim_end_matches('/')))
436}
437
438pub fn parse_invite_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
441 let (locator, fragment) = input.trim().split_once('#').ok_or(InviteError::BadLink("no fragment"))?;
442 if fragment.is_empty() {
443 return Err(InviteError::BadLink("empty fragment"));
444 }
445 let naddr = match locator.find(INVITE_PATH) {
447 Some(i) => locator[i + INVITE_PATH.len()..].trim_end_matches('/'),
448 None => locator.trim_start_matches("nostr:"),
449 };
450 let link_signer = signer_from_naddr(naddr)?;
451 let (token, bootstrap_relays) = decode_fragment(fragment)?;
452 Ok(ParsedInviteLink { link_signer, token, bootstrap_relays, naddr: naddr.to_string() })
453}
454
455#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
461pub struct InviteEntry {
462 pub token: String,
463 pub signer_sk: String,
464 pub community_id: String,
465 pub url: String,
466 #[serde(skip_serializing_if = "Option::is_none")]
467 pub label: Option<String>,
468 pub created_at: u64,
469 #[serde(skip_serializing_if = "Option::is_none")]
470 pub expires_at: Option<u64>,
471 #[serde(flatten)]
472 pub extra: serde_json::Map<String, serde_json::Value>,
473}
474
475#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
477pub struct InviteTombstone {
478 pub token: String,
479 pub community_id: String,
480 #[serde(flatten)]
481 pub extra: serde_json::Map<String, serde_json::Value>,
482}
483
484#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
487pub struct InviteList {
488 #[serde(default)]
489 pub entries: Vec<InviteEntry>,
490 #[serde(default)]
491 pub tombstones: Vec<InviteTombstone>,
492 #[serde(flatten)]
493 pub extra: serde_json::Map<String, serde_json::Value>,
494}
495
496pub fn merge_invite_lists(a: InviteList, b: InviteList) -> InviteList {
501 use std::collections::BTreeMap;
502
503 let mut entries: BTreeMap<String, InviteEntry> = BTreeMap::new();
504 for e in a.entries.into_iter().chain(b.entries) {
505 entries.entry(e.token.clone()).or_insert(e);
506 }
507 let mut tombstones: BTreeMap<String, InviteTombstone> = BTreeMap::new();
508 for t in a.tombstones.into_iter().chain(b.tombstones) {
509 tombstones.entry(t.token.clone()).or_insert(t);
510 }
511 for token in tombstones.keys() {
512 entries.remove(token);
513 }
514 let mut extra = a.extra;
515 extra.extend(b.extra);
516 InviteList {
517 entries: entries.into_values().collect(),
518 tombstones: tombstones.into_values().collect(),
519 extra,
520 }
521}
522
523pub fn build_invite_list_event(my_keys: &Keys, list: &InviteList) -> Result<Event, InviteError> {
527 use nostr_sdk::prelude::nip44::{encrypt, Version};
528 let json = serde_json::to_string(list).map_err(|e| InviteError::Json(e.to_string()))?;
529 let content = encrypt(my_keys.secret_key(), &my_keys.public_key(), json.as_bytes(), Version::V2).map_err(|e| InviteError::Crypto(e.to_string()))?;
530 EventBuilder::new(Kind::Custom(kind::INVITE_LIST), content)
531 .finalize(my_keys)
532 .map_err(|e| InviteError::Crypto(e.to_string()))
533}
534
535pub fn parse_invite_list_event(event: &Event, my_keys: &Keys) -> Result<InviteList, InviteError> {
539 use nostr_sdk::prelude::nip44::decrypt;
540 if event.kind.as_u16() != kind::INVITE_LIST {
541 return Err(InviteError::BadEvent("not a kind-13303 invite list"));
542 }
543 let json = decrypt(my_keys.secret_key(), &my_keys.public_key(), &event.content).map_err(|e| InviteError::Crypto(e.to_string()))?;
544 serde_json::from_str(&json).map_err(|e| InviteError::Json(e.to_string()))
545}
546
547pub fn build_registry_content(signers: &[PublicKey]) -> String {
555 let hexes: Vec<String> = signers.iter().map(|p| p.to_hex()).collect();
556 serde_json::to_string(&hexes).expect("Vec<String> always serializes")
557}
558
559pub fn parse_registry_content(content: &str) -> Result<Vec<PublicKey>, InviteError> {
561 let hexes: Vec<String> = serde_json::from_str(content).map_err(|e| InviteError::Json(e.to_string()))?;
562 hexes
563 .iter()
564 .map(|h| PublicKey::from_hex(h).map_err(|_| InviteError::BadHex("registry signer")))
565 .collect()
566}
567
568pub fn build_direct_invite(
579 inviter_keys: &Keys,
580 recipient: &PublicKey,
581 bundle: &CommunityInvite,
582) -> Result<Event, InviteError> {
583 let json = serde_json::to_string(bundle).map_err(|e| InviteError::Json(e.to_string()))?;
584
585 let mut rumor = EventBuilder::new(Kind::Custom(kind::DIRECT_INVITE), json)
587 .custom_created_at(Timestamp::now())
588 .finalize_unsigned_with_id(inviter_keys.public_key());
589 rumor.ensure_id();
590
591 let seal_content = nip44::encrypt(inviter_keys.secret_key(), recipient, rumor.as_json(), nip44::Version::default())
592 .map_err(|e| InviteError::Crypto(e.to_string()))?;
593 let seal = EventBuilder::new(Kind::Seal, seal_content)
594 .custom_created_at(crate::sending::tweaked_timestamp())
595 .finalize(inviter_keys)
596 .map_err(|e| InviteError::Crypto(e.to_string()))?;
597
598 let ephemeral = Keys::generate();
599 let wrap_content = nip44::encrypt(ephemeral.secret_key(), recipient, seal.as_json(), nip44::Version::default())
600 .map_err(|e| InviteError::Crypto(e.to_string()))?;
601 let mut tags = vec![
602 Tag::public_key(*recipient),
603 Tag::custom("k", [kind::DIRECT_INVITE.to_string()]),
604 ];
605 if let Some(ms) = bundle.expires_at {
606 tags.push(Tag::custom("expiration", [(ms / 1000).to_string()]));
607 }
608 EventBuilder::new(Kind::GiftWrap, wrap_content)
609 .tags(tags)
610 .custom_created_at(crate::sending::tweaked_timestamp())
611 .finalize(&ephemeral)
612 .map_err(|e| InviteError::Crypto(e.to_string()))
613}
614
615pub fn unwrap_direct_invite(wrap: &Event, recipient_keys: &Keys) -> Result<(PublicKey, CommunityInvite), InviteError> {
622 if wrap.kind != Kind::GiftWrap {
623 return Err(InviteError::BadEvent("not a gift wrap"));
624 }
625 let seal_json = nip44::decrypt(recipient_keys.secret_key(), &wrap.pubkey, &wrap.content)
626 .map_err(|e| InviteError::Crypto(e.to_string()))?;
627 let seal = Event::from_json(&seal_json).map_err(|e| InviteError::Json(e.to_string()))?;
628 if seal.kind != Kind::Seal {
629 return Err(InviteError::BadEvent("inner is not a seal"));
630 }
631 seal.verify().map_err(|_| InviteError::BadEvent("seal signature invalid"))?;
632
633 let rumor_json = nip44::decrypt(recipient_keys.secret_key(), &seal.pubkey, &seal.content)
634 .map_err(|e| InviteError::Crypto(e.to_string()))?;
635 let rumor = UnsignedEvent::from_json(rumor_json.as_bytes()).map_err(|e| InviteError::Json(e.to_string()))?;
636 if rumor.kind.as_u16() != kind::DIRECT_INVITE {
637 return Err(InviteError::BadEvent("rumor is not a direct invite"));
638 }
639 if rumor.pubkey != seal.pubkey {
640 return Err(InviteError::BadEvent("rumor author does not match the seal signer"));
641 }
642 let bundle = CommunityInvite::from_bundle_json(&rumor.content)?;
643 Ok((seal.pubkey, bundle))
644}
645
646pub async fn build_invite_list_event_signed<S: crate::signer::VectorSigner + ?Sized>(
651 signer: &S,
652 my_pk: PublicKey,
653 list: &InviteList,
654) -> Result<Event, InviteError> {
655 let json = serde_json::to_string(list).map_err(|e| InviteError::Json(e.to_string()))?;
656 let content = signer.nip44_encrypt_async(&my_pk, &json).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
657 let unsigned = EventBuilder::new(Kind::Custom(kind::INVITE_LIST), content).finalize_unsigned_with_id(my_pk);
658 signer.sign_event_async(unsigned).await.map_err(|e| InviteError::Crypto(e.to_string()))
659}
660
661pub async fn parse_invite_list_event_signed<S: crate::signer::VectorSigner + ?Sized>(
663 signer: &S,
664 my_pk: PublicKey,
665 event: &Event,
666) -> Result<InviteList, InviteError> {
667 if event.kind.as_u16() != kind::INVITE_LIST {
668 return Err(InviteError::BadEvent("not a kind-13303 invite list"));
669 }
670 let json = signer.nip44_decrypt_async(&my_pk, &event.content).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
671 serde_json::from_str(&json).map_err(|e| InviteError::Json(e.to_string()))
672}
673
674pub async fn build_direct_invite_signed<S: crate::signer::VectorSigner + ?Sized>(
677 signer: &S,
678 inviter_pk: PublicKey,
679 recipient: &PublicKey,
680 bundle: &CommunityInvite,
681) -> Result<Event, InviteError> {
682 let json = serde_json::to_string(bundle).map_err(|e| InviteError::Json(e.to_string()))?;
683 let mut rumor = EventBuilder::new(Kind::Custom(kind::DIRECT_INVITE), json)
684 .custom_created_at(Timestamp::now())
685 .finalize_unsigned_with_id(inviter_pk);
686 rumor.ensure_id();
687 let rumor_json = rumor.as_json();
688 let seal_content = signer.nip44_encrypt_async(recipient, &rumor_json).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
689 let seal_unsigned = EventBuilder::new(Kind::Seal, seal_content)
690 .custom_created_at(crate::sending::tweaked_timestamp())
691 .finalize_unsigned_with_id(inviter_pk);
692 let seal = signer.sign_event_async(seal_unsigned).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
693 let ephemeral = Keys::generate();
694 let wrap_content = nip44::encrypt(ephemeral.secret_key(), recipient, seal.as_json(), nip44::Version::default())
695 .map_err(|e| InviteError::Crypto(e.to_string()))?;
696 let mut tags = vec![
697 Tag::public_key(*recipient),
698 Tag::custom("k", [kind::DIRECT_INVITE.to_string()]),
699 ];
700 if let Some(ms) = bundle.expires_at {
701 tags.push(Tag::custom("expiration", [(ms / 1000).to_string()]));
702 }
703 EventBuilder::new(Kind::GiftWrap, wrap_content)
704 .tags(tags)
705 .custom_created_at(crate::sending::tweaked_timestamp())
706 .finalize(&ephemeral)
707 .map_err(|e| InviteError::Crypto(e.to_string()))
708}
709
710pub async fn unwrap_direct_invite_signed<S: crate::signer::VectorSigner + ?Sized>(
713 signer: &S,
714 wrap: &Event,
715) -> Result<(PublicKey, CommunityInvite), InviteError> {
716 if wrap.kind != Kind::GiftWrap {
717 return Err(InviteError::BadEvent("not a gift wrap"));
718 }
719 let seal_json = signer.nip44_decrypt_async(&wrap.pubkey, &wrap.content).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
720 let seal = Event::from_json(&seal_json).map_err(|e| InviteError::Json(e.to_string()))?;
721 if seal.kind != Kind::Seal {
722 return Err(InviteError::BadEvent("inner is not a seal"));
723 }
724 seal.verify().map_err(|_| InviteError::BadEvent("seal signature invalid"))?;
725 let rumor_json = signer.nip44_decrypt_async(&seal.pubkey, &seal.content).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
726 let rumor = UnsignedEvent::from_json(rumor_json.as_bytes()).map_err(|e| InviteError::Json(e.to_string()))?;
727 if rumor.kind.as_u16() != kind::DIRECT_INVITE {
728 return Err(InviteError::BadEvent("rumor is not a direct invite"));
729 }
730 if rumor.pubkey != seal.pubkey {
731 return Err(InviteError::BadEvent("rumor author does not match the seal signer"));
732 }
733 let bundle = CommunityInvite::from_bundle_json(&rumor.content)?;
734 Ok((seal.pubkey, bundle))
735}
736
737fn hex32(s: &str, field: &'static str) -> Result<[u8; 32], InviteError> {
740 crate::simd::hex::hex_to_bytes_32_checked(s).ok_or(InviteError::BadHex(field))
741}
742
743fn dict_id(url: &str) -> Option<u8> {
744 RELAY_DICT.iter().position(|u| *u == url).map(|i| (i + 1) as u8)
745}
746
747fn dict_url(id: u8) -> Option<&'static str> {
748 RELAY_DICT.get((id as usize).checked_sub(1)?).copied()
749}
750
751fn d_empty() -> Tag {
752 Tag::custom("d", [""])
753}
754
755fn vsk_tag(value: &str) -> Tag {
756 Tag::custom("vsk", [value])
757}
758
759fn first_tag(event: &Event, name: &str) -> Option<String> {
760 event.tags.iter().find_map(|t| {
761 let s = t.as_slice();
762 (s.len() >= 2 && s[0] == name).then(|| s[1].clone())
763 })
764}
765
766pub(crate) fn seal_bundle(bundle_key: &[u8; 32], json: &str) -> Result<String, InviteError> {
770 let ck = ConversationKey::new(*bundle_key);
771 let ct = crate::community::cipher::encrypt_with_random_nonce(&ck, json.as_bytes()).map_err(|e| InviteError::Crypto(e.to_string()))?;
772 Ok(base64_simd::STANDARD.encode_to_string(&ct))
773}
774
775fn open_bundle(bundle_key: &[u8; 32], content: &str) -> Result<String, InviteError> {
776 let ck = ConversationKey::new(*bundle_key);
777 let ct = base64_simd::STANDARD
778 .decode_to_vec(content.as_bytes())
779 .map_err(|e| InviteError::Crypto(e.to_string()))?;
780 let pt = decrypt_to_bytes(&ck, &ct).map_err(|e| InviteError::Crypto(e.to_string()))?;
781 String::from_utf8(pt).map_err(|e| InviteError::Crypto(e.to_string()))
782}
783
784fn signer_from_naddr(naddr: &str) -> Result<PublicKey, InviteError> {
785 let trimmed = naddr.trim().trim_start_matches("nostr:");
786 let n19 = nostr_sdk::prelude::nip19::Nip19::from_bech32(trimmed).map_err(|_| InviteError::BadLink("invalid naddr"))?;
787 match n19 {
788 nostr_sdk::prelude::nip19::Nip19::Coordinate(c)
789 if c.coordinate.kind.as_u16() == kind::INVITE_BUNDLE && c.coordinate.identifier.is_empty() =>
790 {
791 Ok(c.coordinate.public_key)
792 }
793 _ => Err(InviteError::BadLink("naddr is not an invite-bundle coordinate")),
794 }
795}
796
797#[cfg(test)]
798mod tests {
799 use super::super::control::CommunityIdentity;
800 use super::super::derive::invite_bundle_key;
801 use super::*;
802 use crate::community::{random_32, MAX_COMMUNITY_RELAYS};
803
804 fn hex(bytes: &[u8; 32]) -> String {
805 crate::simd::hex::bytes_to_hex_32(bytes)
806 }
807
808 #[test]
809 fn a_bundle_vending_no_channel_keys_omits_the_field_entirely() {
810 let json = r#"{
814 "community_id":"aa","owner":"bb","owner_salt":"cc",
815 "community_root":"dd","root_epoch":0,"name":"Public Room"
816 }"#;
817 let inv: CommunityInvite = serde_json::from_str(json).expect("absent `channels`/`relays` must parse");
818 assert!(inv.channels.is_empty());
819 assert!(inv.relays.is_empty());
820 assert_eq!(inv.icon, None, "absent optionals are None, not an error");
821 assert_eq!(inv.expires_at, None);
822 assert_eq!(inv.name, "Public Room");
823 }
824
825 fn valid_bundle() -> (Keys, CommunityInvite) {
827 let owner = Keys::generate();
828 let id = CommunityIdentity::mint(&owner.public_key());
829 let bundle = CommunityInvite {
830 community_id: hex(&id.community_id.0),
831 owner: hex(&id.owner_xonly),
832 owner_salt: hex(&id.owner_salt),
833 community_root: hex(&random_32()),
834 root_epoch: 0,
835 control_pk: None,
836 channels: vec![],
837 relays: vec!["wss://a.example".into(), "wss://b.example".into()],
838 name: "Test community".into(),
839 icon: None,
840 expires_at: None,
841 creator_npub: None,
842 label: None,
843 extra: Default::default(),
844 };
845 (owner, bundle)
846 }
847
848 fn token16() -> [u8; TOKEN_LEN] {
849 std::array::from_fn(|i| i as u8) }
851
852 #[test]
855 fn validate_accepts_a_correct_owner_salt_id_triple() {
856 let (_owner, bundle) = valid_bundle();
857 assert!(bundle.validate().is_ok());
858 }
859
860 #[test]
861 fn validate_rejects_a_forged_owner() {
862 let (_owner, mut bundle) = valid_bundle();
863 let attacker = Keys::generate();
865 bundle.owner = hex(&attacker.public_key().to_bytes());
866 assert!(matches!(bundle.validate(), Err(InviteError::OwnerMismatch)));
867 }
868
869 #[test]
870 fn from_json_rejects_over_the_channel_cap() {
871 let (_owner, mut bundle) = valid_bundle();
872 bundle.channels = (0..MAX_BUNDLE_CHANNELS + 1)
873 .map(|_| ChannelGrant { id: hex(&random_32()), key: hex(&random_32()), epoch: 0, name: "x".into() })
874 .collect();
875 let json = serde_json::to_string(&bundle).unwrap();
876 assert!(matches!(
877 CommunityInvite::from_bundle_json(&json),
878 Err(InviteError::TooManyChannels(n)) if n == MAX_BUNDLE_CHANNELS + 1
879 ));
880 }
881
882 #[test]
883 fn from_json_truncates_relays_to_the_community_cap() {
884 let (_owner, mut bundle) = valid_bundle();
885 bundle.relays = (0..MAX_COMMUNITY_RELAYS + 3).map(|i| format!("wss://r{i}.example")).collect();
886 let json = serde_json::to_string(&bundle).unwrap();
887 let parsed = CommunityInvite::from_bundle_json(&json).unwrap();
888 assert_eq!(parsed.relays.len(), MAX_COMMUNITY_RELAYS);
889 }
890
891 #[test]
892 fn unknown_bundle_fields_round_trip() {
893 let (_owner, bundle) = valid_bundle();
896 let mut value = serde_json::to_value(&bundle).unwrap();
897 let obj = value.as_object_mut().unwrap();
898 obj.insert("held_roots".into(), serde_json::json!([{ "epoch": 2, "key": "ab" }]));
899 obj.insert("refounder".into(), serde_json::json!("deadbeef"));
900 obj.insert("future_field".into(), serde_json::json!({ "deep": [1, 2] }));
901 let json = serde_json::to_string(&value).unwrap();
902
903 let parsed = CommunityInvite::from_bundle_json(&json).unwrap();
904 let out: serde_json::Value = serde_json::from_str(&serde_json::to_string(&parsed).unwrap()).unwrap();
905 assert_eq!(out["held_roots"][0]["epoch"], 2);
906 assert_eq!(out["refounder"], "deadbeef");
907 assert_eq!(out["future_field"]["deep"][1], 2);
908 }
909
910 #[test]
911 fn expiry_is_a_join_gate_not_a_render_gate() {
912 let (_owner, mut bundle) = valid_bundle();
913 bundle.expires_at = Some(1_000_000);
914 assert!(!bundle.expired(999_999));
915 assert!(bundle.expired(1_000_001));
916 }
917
918 #[test]
921 fn fragment_golden_stock_set() {
922 let token = token16();
926 let frag = encode_fragment(&token, &stock_relays()).unwrap();
927 assert_eq!(frag, "BAEAAQIDBAUGBwgJCgsMDQ4P");
928 let (t, relays) = decode_fragment(&frag).unwrap();
929 assert_eq!(t, token);
930 assert_eq!(relays, stock_relays());
931 }
932
933 #[test]
934 fn fragment_golden_dictionary_id_mix() {
935 let token = token16();
939 let relays = vec![RELAY_DICT[1].to_string(), RELAY_DICT[3].to_string()];
940 let frag = encode_fragment(&token, &relays).unwrap();
941 assert_eq!(frag, "BAACAgQAAQIDBAUGBwgJCgsMDQ4P");
942 let (t, out) = decode_fragment(&frag).unwrap();
943 assert_eq!(t, token);
944 assert_eq!(out, relays);
945 }
946
947 #[test]
948 fn fragment_golden_wss_implied_literal() {
949 let token = token16();
953 let relays = vec!["wss://x.y".to_string()];
954 let frag = encode_fragment(&token, &relays).unwrap();
955 assert_eq!(frag, "BAABAAN4LnkAAQIDBAUGBwgJCgsMDQ4P");
956 let (t, out) = decode_fragment(&frag).unwrap();
957 assert_eq!(t, token);
958 assert_eq!(out, relays);
959 }
960
961 #[test]
962 fn fragment_golden_verbatim_literal() {
963 let token = token16();
967 let relays = vec!["ws://h".to_string()];
968 let frag = encode_fragment(&token, &relays).unwrap();
969 assert_eq!(frag, "BAAB_wZ3czovL2gAAQIDBAUGBwgJCgsMDQ4P");
970 let (t, out) = decode_fragment(&frag).unwrap();
971 assert_eq!(t, token);
972 assert_eq!(out, relays);
973 }
974
975 #[test]
976 fn fragment_rejects_wrong_version_both_directions() {
977 let token = token16();
978 for bad in [3u8, 5u8] {
979 let mut bytes = vec![bad, FLAG_STOCK_SET];
980 bytes.extend_from_slice(&token);
981 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
982 assert!(matches!(decode_fragment(&frag), Err(InviteError::BadVersion(v)) if v == bad));
983 }
984 }
985
986 #[test]
987 fn fragment_rejects_trailing_bytes() {
988 let token = token16();
989 let mut bytes = vec![FRAGMENT_VERSION, FLAG_STOCK_SET];
990 bytes.extend_from_slice(&token);
991 bytes.push(0xff); let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
993 assert!(matches!(decode_fragment(&frag), Err(InviteError::BadFragment("trailing bytes"))));
994 }
995
996 #[test]
997 fn fragment_rejects_count_over_three() {
998 let token = token16();
999 let mut bytes = vec![FRAGMENT_VERSION, 0x00, 0x04]; bytes.extend_from_slice(&[1, 2, 3, 4]);
1001 bytes.extend_from_slice(&token);
1002 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
1003 assert!(matches!(decode_fragment(&frag), Err(InviteError::BadFragment("too many bootstrap relays"))));
1004 }
1005
1006 #[test]
1007 fn fragment_skips_an_unknown_dictionary_id() {
1008 let token = token16();
1009 let mut bytes = vec![FRAGMENT_VERSION, 0x00, 0x01, 200];
1011 bytes.extend_from_slice(&token);
1012 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
1013 let (t, relays) = decode_fragment(&frag).unwrap();
1014 assert_eq!(t, token);
1015 assert!(relays.is_empty());
1016 }
1017
1018 #[test]
1019 fn fragment_caps_bootstrap_relays_at_three() {
1020 let token = token16();
1021 let relays: Vec<String> = (0..4).map(|i| format!("wss://r{i}.example")).collect();
1022 let (_t, out) = decode_fragment(&encode_fragment(&token, &relays).unwrap()).unwrap();
1023 assert_eq!(out.len(), MAX_BOOTSTRAP_RELAYS);
1024 }
1025
1026 #[test]
1029 fn bundle_event_round_trips_live() {
1030 let (_owner, bundle) = valid_bundle();
1031 let link = Keys::generate();
1032 let token = random_32();
1033 let key = invite_bundle_key(&token[..TOKEN_LEN].try_into().unwrap());
1034 let event = build_bundle_event(&link, &bundle, &key).unwrap();
1035 assert_eq!(event.pubkey, link.public_key());
1036
1037 match parse_bundle_event(&event, &link.public_key(), &key).unwrap() {
1038 BundleState::Live(b) => {
1039 assert_eq!(b.community_id, bundle.community_id);
1040 assert_eq!(b.name, "Test community");
1041 }
1042 BundleState::Revoked => panic!("expected Live"),
1043 }
1044 }
1045
1046 #[test]
1047 fn revocation_reads_as_revoked() {
1048 let link = Keys::generate();
1049 let tomb = build_revocation(&link).unwrap();
1050 let key = invite_bundle_key(&[0u8; TOKEN_LEN]);
1051 assert!(matches!(parse_bundle_event(&tomb, &link.public_key(), &key), Ok(BundleState::Revoked)));
1052 }
1053
1054 #[test]
1055 fn bundle_event_wrong_key_fails() {
1056 let (_owner, bundle) = valid_bundle();
1057 let link = Keys::generate();
1058 let key = invite_bundle_key(&[7u8; TOKEN_LEN]);
1059 let event = build_bundle_event(&link, &bundle, &key).unwrap();
1060 let wrong = invite_bundle_key(&[8u8; TOKEN_LEN]);
1061 assert!(parse_bundle_event(&event, &link.public_key(), &wrong).is_err());
1062 }
1063
1064 #[test]
1065 fn bundle_event_from_a_different_author_is_rejected() {
1066 let (_owner, bundle) = valid_bundle();
1069 let real = Keys::generate();
1070 let squatter = Keys::generate();
1071 let key = invite_bundle_key(&[3u8; TOKEN_LEN]);
1072 let event = build_bundle_event(&squatter, &bundle, &key).unwrap();
1073 assert!(matches!(
1074 parse_bundle_event(&event, &real.public_key(), &key),
1075 Err(InviteError::BadEvent("author is not the link signer"))
1076 ));
1077 }
1078
1079 #[test]
1083 fn bundle_event_at_a_non_empty_d_is_rejected() {
1084 let (_owner, bundle) = valid_bundle();
1085 let link = Keys::generate();
1086 let key = invite_bundle_key(&[9u8; TOKEN_LEN]);
1087 let json = serde_json::to_string(&bundle).unwrap();
1088 let content = seal_bundle(&key, &json).unwrap();
1089 let event = EventBuilder::new(Kind::Custom(kind::INVITE_BUNDLE), content)
1090 .tags([Tag::identifier("elsewhere"), vsk_tag(vsk::INVITE_LIVE)])
1091 .finalize(&link)
1092 .unwrap();
1093 assert!(matches!(
1094 parse_bundle_event(&event, &link.public_key(), &key),
1095 Err(InviteError::BadEvent("bundle is not at the link's coordinate"))
1096 ));
1097 }
1098
1099 #[test]
1100 fn bundle_event_with_a_tampered_signature_is_rejected() {
1101 let (_owner, bundle) = valid_bundle();
1102 let link = Keys::generate();
1103 let other = Keys::generate();
1104 let key = invite_bundle_key(&[4u8; TOKEN_LEN]);
1105 let event = build_bundle_event(&link, &bundle, &key).unwrap();
1106 let mut json: serde_json::Value = serde_json::from_str(&event.as_json()).unwrap();
1108 json["pubkey"] = serde_json::Value::String(other.public_key().to_hex());
1109 let Ok(forged) = Event::from_json(json.to_string()) else { return };
1110 assert!(matches!(
1111 parse_bundle_event(&forged, &other.public_key(), &key),
1112 Err(InviteError::BadEvent("signature invalid"))
1113 ));
1114 }
1115
1116 fn entry(token: &str, community: &str) -> InviteEntry {
1119 InviteEntry {
1120 token: token.into(),
1121 signer_sk: "bb".repeat(32),
1122 community_id: community.into(),
1123 url: "https://x/invite/naddr1xyz#frag".into(),
1124 label: None,
1125 created_at: 1000,
1126 expires_at: None,
1127 extra: Default::default(),
1128 }
1129 }
1130
1131 #[test]
1132 fn invite_list_merge_entries_immutable_tombstone_wins_terminally() {
1133 let e = entry(&"aa".repeat(16), &"cc".repeat(32));
1134 let a = merge_invite_lists(InviteList::default(), InviteList { entries: vec![e.clone()], ..Default::default() });
1135 assert_eq!(a.entries.len(), 1);
1136
1137 let mut mutated = e.clone();
1139 mutated.label = Some("changed".into());
1140 let still = merge_invite_lists(a.clone(), InviteList { entries: vec![mutated], ..Default::default() });
1141 assert_eq!(still.entries[0].label, None);
1142
1143 let tomb = InviteTombstone { token: e.token.clone(), community_id: e.community_id.clone(), extra: Default::default() };
1145 let b = merge_invite_lists(a, InviteList { tombstones: vec![tomb], ..Default::default() });
1146 assert!(b.entries.is_empty());
1147 assert_eq!(b.tombstones.len(), 1);
1148
1149 let c = merge_invite_lists(b, InviteList { entries: vec![e], ..Default::default() });
1151 assert!(c.entries.is_empty());
1152 }
1153
1154 #[test]
1155 fn invite_list_tombstones_union() {
1156 let t1 = InviteTombstone { token: "11".repeat(16), community_id: "aa".repeat(32), extra: Default::default() };
1157 let t2 = InviteTombstone { token: "22".repeat(16), community_id: "bb".repeat(32), extra: Default::default() };
1158 let merged = merge_invite_lists(
1159 InviteList { tombstones: vec![t1], ..Default::default() },
1160 InviteList { tombstones: vec![t2], ..Default::default() },
1161 );
1162 assert_eq!(merged.tombstones.len(), 2);
1163 }
1164
1165 #[test]
1166 fn invite_list_unknown_fields_round_trip() {
1167 let wire = r#"{"entries":[],"tombstones":[],"schema":"future","note":{"x":1}}"#;
1168 let list: InviteList = serde_json::from_str(wire).unwrap();
1169 let out: serde_json::Value = serde_json::from_str(&serde_json::to_string(&list).unwrap()).unwrap();
1170 assert_eq!(out["schema"], "future");
1171 assert_eq!(out["note"]["x"], 1);
1172 }
1173
1174 #[test]
1177 fn direct_invite_round_trips_to_inviter_and_bundle() {
1178 let inviter = Keys::generate();
1179 let recipient = Keys::generate();
1180 let (_owner, bundle) = valid_bundle();
1181
1182 let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1183 assert_eq!(wrap.kind, Kind::GiftWrap);
1184 assert_ne!(wrap.pubkey, inviter.public_key(), "wrap author is ephemeral");
1185 assert!(wrap.tags.iter().any(|t| {
1186 let s = t.as_slice();
1187 s.len() >= 2 && s[0] == "k" && s[1] == kind::DIRECT_INVITE.to_string()
1188 }));
1189
1190 let (sender, out) = unwrap_direct_invite(&wrap, &recipient).unwrap();
1191 assert_eq!(sender, inviter.public_key());
1192 assert_eq!(out.community_id, bundle.community_id);
1193 assert_eq!(out.name, "Test community");
1194 }
1195
1196 #[test]
1197 fn direct_invite_rejects_a_seal_claiming_a_false_inviter() {
1198 let inviter = Keys::generate();
1201 let claimed = Keys::generate();
1202 let recipient = Keys::generate();
1203 let (_owner, bundle) = valid_bundle();
1204
1205 let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1207 let seal_json = nip44::decrypt(recipient.secret_key(), &wrap.pubkey, &wrap.content).unwrap();
1208 let mut seal_val: serde_json::Value = serde_json::from_str(&seal_json).unwrap();
1209 seal_val["pubkey"] = serde_json::Value::String(claimed.public_key().to_hex());
1210 let ephemeral = Keys::generate();
1212 let wrap_ct =
1213 nip44::encrypt(ephemeral.secret_key(), &recipient.public_key(), seal_val.to_string(), nip44::Version::default())
1214 .unwrap();
1215 let forged = EventBuilder::new(Kind::GiftWrap, wrap_ct)
1216 .tags([Tag::public_key(recipient.public_key())])
1217 .finalize(&ephemeral)
1218 .unwrap();
1219 assert!(unwrap_direct_invite(&forged, &recipient).is_err());
1220 }
1221
1222 #[test]
1223 fn direct_invite_rejects_a_non_invite_rumor_kind() {
1224 let inviter = Keys::generate();
1225 let recipient = Keys::generate();
1226 let (_owner, bundle) = valid_bundle();
1227 let json = serde_json::to_string(&bundle).unwrap();
1228
1229 let mut rumor = EventBuilder::new(Kind::Custom(9), json)
1231 .custom_created_at(Timestamp::now())
1232 .finalize_unsigned_with_id(inviter.public_key());
1233 rumor.ensure_id();
1234 let seal_ct =
1235 nip44::encrypt(inviter.secret_key(), &recipient.public_key(), rumor.as_json(), nip44::Version::default()).unwrap();
1236 let seal = EventBuilder::new(Kind::Seal, seal_ct).finalize(&inviter).unwrap();
1237 let ephemeral = Keys::generate();
1238 let wrap_ct =
1239 nip44::encrypt(ephemeral.secret_key(), &recipient.public_key(), seal.as_json(), nip44::Version::default()).unwrap();
1240 let wrap = EventBuilder::new(Kind::GiftWrap, wrap_ct)
1241 .tags([Tag::public_key(recipient.public_key())])
1242 .finalize(&ephemeral)
1243 .unwrap();
1244 assert!(matches!(
1245 unwrap_direct_invite(&wrap, &recipient),
1246 Err(InviteError::BadEvent("rumor is not a direct invite"))
1247 ));
1248 }
1249
1250 #[test]
1251 fn direct_invite_stamps_nip40_expiration_in_seconds() {
1252 let inviter = Keys::generate();
1253 let recipient = Keys::generate();
1254 let (_owner, mut bundle) = valid_bundle();
1255 let expires_ms = 1_735_689_600_000u64;
1256 bundle.expires_at = Some(expires_ms);
1257
1258 let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1259 let exp = wrap.tags.iter().find_map(|t| {
1260 let s = t.as_slice();
1261 (s.len() >= 2 && s[0] == "expiration").then(|| s[1].clone())
1262 });
1263 assert_eq!(exp, Some((expires_ms / 1000).to_string()));
1264 }
1265
1266 #[test]
1267 fn direct_invite_rejects_a_forged_owner_bundle() {
1268 let inviter = Keys::generate();
1269 let recipient = Keys::generate();
1270 let (_owner, mut bundle) = valid_bundle();
1271 bundle.owner = hex(&Keys::generate().public_key().to_bytes()); let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1273 assert!(matches!(unwrap_direct_invite(&wrap, &recipient), Err(InviteError::OwnerMismatch)));
1274 }
1275
1276 #[test]
1279 fn invite_link_round_trips_full_url_and_bare_form() {
1280 let link = Keys::generate();
1281 let token = token16();
1282 let relays = vec!["wss://a.example".to_string()];
1283 let url = build_invite_url("https://vectorapp.io", &link.public_key(), &token, &relays).unwrap();
1284 assert!(url.contains("/invite/naddr1"));
1285
1286 let parsed = parse_invite_link(&url).unwrap();
1287 assert_eq!(parsed.link_signer, link.public_key());
1288 assert_eq!(parsed.token, token);
1289 assert_eq!(parsed.bootstrap_relays, relays);
1290
1291 let bare = format!("{}#{}", parsed.naddr, url.split('#').nth(1).unwrap());
1292 assert_eq!(parse_invite_link(&bare).unwrap().link_signer, link.public_key());
1293 }
1294
1295 #[test]
1296 fn invite_link_rejects_non_invites() {
1297 assert!(parse_invite_link("hello world").is_err());
1298 assert!(parse_invite_link("wss://relay.example.com").is_err());
1299 assert!(parse_invite_link("https://x/invite/#frag").is_err());
1300 }
1301
1302 #[test]
1303 fn v2_parser_rejects_a_v1_style_url_no_cross_protocol_confusion() {
1304 let v1_url = "https://vectorapp.io/invite#AmR1bW15djFmcmFnbWVudA";
1310 assert!(parse_invite_link(v1_url).is_err(), "v2 must reject a v1-format invite URL");
1311 assert!(parse_invite_link("https://vectorapp.io/invite/#AmR1bW15").is_err());
1313 let token = [0x07u8; TOKEN_LEN];
1315 let signer = Keys::generate();
1316 let naddr = bundle_naddr(&signer.public_key()).unwrap();
1317 let frag = encode_fragment(&token, &[]).unwrap();
1318 assert!(parse_invite_link(&format!("{naddr}#{frag}")).is_ok());
1319 }
1320
1321 #[test]
1324 fn registry_content_round_trips_and_leaks_no_secrets() {
1325 let a = Keys::generate();
1326 let b = Keys::generate();
1327 let content = build_registry_content(&[a.public_key(), b.public_key()]);
1328
1329 let arr: Vec<String> = serde_json::from_str(&content).unwrap();
1331 assert_eq!(arr, vec![a.public_key().to_hex(), b.public_key().to_hex()]);
1332 assert!(!content.contains("invite"));
1334 assert!(!content.contains("token"));
1335 assert!(!content.contains("://"));
1336
1337 let back = parse_registry_content(&content).unwrap();
1338 assert_eq!(back, vec![a.public_key(), b.public_key()]);
1339 }
1340}