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(default)]
145 pub channels: Vec<ChannelGrant>,
146 #[serde(default)]
147 pub relays: Vec<String>,
148 pub name: String,
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub icon: Option<ImageRef>,
152 #[serde(skip_serializing_if = "Option::is_none")]
154 pub expires_at: Option<u64>,
155 #[serde(skip_serializing_if = "Option::is_none")]
157 pub creator_npub: Option<String>,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub label: Option<String>,
160 #[serde(flatten)]
163 pub extra: serde_json::Map<String, serde_json::Value>,
164}
165
166impl CommunityInvite {
167 pub fn from_bundle_json(json: &str) -> Result<Self, InviteError> {
171 let mut bundle: CommunityInvite = serde_json::from_str(json).map_err(|e| InviteError::Json(e.to_string()))?;
172 if bundle.channels.len() > MAX_BUNDLE_CHANNELS {
173 return Err(InviteError::TooManyChannels(bundle.channels.len()));
174 }
175 bundle.relays = cap_relays(std::mem::take(&mut bundle.relays));
176 bundle.validate()?;
177 Ok(bundle)
178 }
179
180 pub fn validate(&self) -> Result<(), InviteError> {
184 if self.channels.len() > MAX_BUNDLE_CHANNELS {
185 return Err(InviteError::TooManyChannels(self.channels.len()));
186 }
187 if self.root_epoch > MAX_BUNDLE_EPOCH || self.channels.iter().any(|c| c.epoch > MAX_BUNDLE_EPOCH) {
192 return Err(InviteError::BadFragment("epoch out of range"));
193 }
194 let owner = hex32(&self.owner, "owner")?;
195 let salt = hex32(&self.owner_salt, "owner_salt")?;
196 let cid = hex32(&self.community_id, "community_id")?;
197 if !verify_community_id(&CommunityId(cid), &owner, &salt) {
198 return Err(InviteError::OwnerMismatch);
199 }
200 Ok(())
201 }
202
203 pub fn expired(&self, now_ms: u64) -> bool {
207 self.expires_at.is_some_and(|e| now_ms > e)
208 }
209}
210
211#[derive(Debug)]
215pub enum BundleState {
216 Live(Box<CommunityInvite>),
218 Revoked,
220}
221
222pub fn build_bundle_event(
227 link_signer: &Keys,
228 bundle: &CommunityInvite,
229 bundle_key: &[u8; 32],
230) -> Result<Event, InviteError> {
231 let json = serde_json::to_string(bundle).map_err(|e| InviteError::Json(e.to_string()))?;
232 let content = seal_bundle(bundle_key, &json)?;
233 EventBuilder::new(Kind::Custom(kind::INVITE_BUNDLE), content)
234 .tags([d_empty(), vsk_tag(vsk::INVITE_LIVE)])
235 .finalize(link_signer)
236 .map_err(|e| InviteError::Crypto(e.to_string()))
237}
238
239pub fn build_revocation(link_signer: &Keys) -> Result<Event, InviteError> {
243 EventBuilder::new(Kind::Custom(kind::INVITE_BUNDLE), "")
244 .tags([d_empty(), vsk_tag(vsk::INVITE_REVOKED)])
245 .finalize(link_signer)
246 .map_err(|e| InviteError::Crypto(e.to_string()))
247}
248
249pub fn parse_bundle_event(
256 event: &Event,
257 expected_signer: &PublicKey,
258 bundle_key: &[u8; 32],
259) -> Result<BundleState, InviteError> {
260 if event.kind.as_u16() != kind::INVITE_BUNDLE {
261 return Err(InviteError::BadEvent("wrong kind"));
262 }
263 if event.pubkey != *expected_signer {
264 return Err(InviteError::BadEvent("author is not the link signer"));
265 }
266 if !first_tag(event, "d").unwrap_or_default().is_empty() {
269 return Err(InviteError::BadEvent("bundle is not at the link's coordinate"));
270 }
271 event.verify().map_err(|_| InviteError::BadEvent("signature invalid"))?;
272
273 match first_tag(event, "vsk").as_deref() {
274 Some(v) if v == vsk::INVITE_REVOKED => return Ok(BundleState::Revoked),
275 Some(v) if v == vsk::INVITE_LIVE => {}
276 _ => return Err(InviteError::BadEvent("unknown or missing bundle marker")),
277 }
278
279 let json = open_bundle(bundle_key, &event.content)?;
280 Ok(BundleState::Live(Box::new(CommunityInvite::from_bundle_json(&json)?)))
281}
282
283const INVITE_PATH: &str = "/invite/";
286
287#[derive(Debug, Clone)]
289pub struct ParsedInviteLink {
290 pub link_signer: PublicKey,
292 pub token: [u8; TOKEN_LEN],
293 pub bootstrap_relays: Vec<String>,
294 pub naddr: String,
296}
297
298pub fn stock_relays() -> Vec<String> {
301 RELAY_DICT.iter().map(|s| s.to_string()).collect()
302}
303
304pub fn encode_fragment(token: &[u8; TOKEN_LEN], relays: &[String]) -> Result<String, InviteError> {
310 let is_stock = relays.len() == RELAY_DICT.len() && relays.iter().zip(RELAY_DICT.iter()).all(|(r, d)| r == d);
311
312 let mut bytes = Vec::with_capacity(2 + TOKEN_LEN + relays.len() * 8);
313 bytes.push(FRAGMENT_VERSION);
314 if is_stock {
315 bytes.push(FLAG_STOCK_SET);
316 } else {
317 bytes.push(0x00);
318 let bounded = &relays[..relays.len().min(MAX_BOOTSTRAP_RELAYS)];
319 bytes.push(bounded.len() as u8);
320 for relay in bounded {
321 if let Some(id) = dict_id(relay) {
322 bytes.push(id);
323 } else if let Some(host) = relay.strip_prefix("wss://") {
324 if host.len() > u8::MAX as usize {
325 return Err(InviteError::BadFragment("relay host too long"));
326 }
327 bytes.extend_from_slice(&[0x00, host.len() as u8]);
328 bytes.extend_from_slice(host.as_bytes());
329 } else {
330 if relay.len() > u8::MAX as usize {
331 return Err(InviteError::BadFragment("relay url too long"));
332 }
333 bytes.extend_from_slice(&[0xff, relay.len() as u8]);
334 bytes.extend_from_slice(relay.as_bytes());
335 }
336 }
337 }
338 bytes.extend_from_slice(token);
339 Ok(base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes))
340}
341
342pub fn decode_fragment(fragment: &str) -> Result<([u8; TOKEN_LEN], Vec<String>), InviteError> {
347 let bytes = base64_simd::URL_SAFE_NO_PAD
348 .decode_to_vec(fragment.trim().as_bytes())
349 .map_err(|_| InviteError::BadFragment("not base64url"))?;
350
351 if bytes.len() < 2 {
352 return Err(InviteError::BadFragment("truncated"));
353 }
354 let version = bytes[0];
355 if version != FRAGMENT_VERSION {
358 return Err(InviteError::BadVersion(version));
359 }
360 let flags = bytes[1];
361 let mut o = 2usize;
362
363 let mut relays = Vec::new();
364 if flags & FLAG_STOCK_SET != 0 {
365 relays = stock_relays();
366 } else {
367 let count = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))? as usize;
368 o += 1;
369 if count > MAX_BOOTSTRAP_RELAYS {
370 return Err(InviteError::BadFragment("too many bootstrap relays"));
371 }
372 for _ in 0..count {
373 let lead = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))?;
374 o += 1;
375 if (1..=254).contains(&lead) {
376 if let Some(url) = dict_url(lead) {
377 relays.push(url.to_string());
378 }
379 } else {
381 let len = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))? as usize;
382 o += 1;
383 let end = o.checked_add(len).ok_or(InviteError::BadFragment("truncated"))?;
384 let raw = bytes.get(o..end).ok_or(InviteError::BadFragment("truncated"))?;
385 let text = std::str::from_utf8(raw).map_err(|_| InviteError::BadFragment("relay not utf8"))?;
386 relays.push(if lead == 255 { text.to_string() } else { format!("wss://{text}") });
387 o = end;
388 }
389 }
390 }
391
392 let end = o.checked_add(TOKEN_LEN).ok_or(InviteError::BadFragment("truncated"))?;
393 let raw = bytes.get(o..end).ok_or(InviteError::BadFragment("truncated"))?;
394 let mut token = [0u8; TOKEN_LEN];
395 token.copy_from_slice(raw);
396 if end != bytes.len() {
397 return Err(InviteError::BadFragment("trailing bytes"));
398 }
399 Ok((token, relays))
400}
401
402pub fn bundle_naddr(link_signer: &PublicKey) -> Result<String, InviteError> {
406 let coord = nostr_sdk::prelude::nip01::Coordinate {
407 kind: Kind::Custom(kind::INVITE_BUNDLE),
408 public_key: *link_signer,
409 identifier: String::new(),
410 };
411 let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate { coordinate: coord, relays: Vec::new() };
412 nostr_sdk::prelude::nip19::Nip19::Coordinate(n19)
413 .to_bech32()
414 .map_err(|e| InviteError::Crypto(e.to_string()))
415}
416
417pub fn build_invite_url(
420 base: &str,
421 link_signer: &PublicKey,
422 token: &[u8; TOKEN_LEN],
423 relays: &[String],
424) -> Result<String, InviteError> {
425 let naddr = bundle_naddr(link_signer)?;
426 let fragment = encode_fragment(token, relays)?;
427 Ok(format!("{}{INVITE_PATH}{naddr}#{fragment}", base.trim_end_matches('/')))
428}
429
430pub fn parse_invite_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
433 let (locator, fragment) = input.trim().split_once('#').ok_or(InviteError::BadLink("no fragment"))?;
434 if fragment.is_empty() {
435 return Err(InviteError::BadLink("empty fragment"));
436 }
437 let naddr = match locator.find(INVITE_PATH) {
439 Some(i) => locator[i + INVITE_PATH.len()..].trim_end_matches('/'),
440 None => locator.trim_start_matches("nostr:"),
441 };
442 let link_signer = signer_from_naddr(naddr)?;
443 let (token, bootstrap_relays) = decode_fragment(fragment)?;
444 Ok(ParsedInviteLink { link_signer, token, bootstrap_relays, naddr: naddr.to_string() })
445}
446
447#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
453pub struct InviteEntry {
454 pub token: String,
455 pub signer_sk: String,
456 pub community_id: String,
457 pub url: String,
458 #[serde(skip_serializing_if = "Option::is_none")]
459 pub label: Option<String>,
460 pub created_at: u64,
461 #[serde(skip_serializing_if = "Option::is_none")]
462 pub expires_at: Option<u64>,
463 #[serde(flatten)]
464 pub extra: serde_json::Map<String, serde_json::Value>,
465}
466
467#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
469pub struct InviteTombstone {
470 pub token: String,
471 pub community_id: String,
472 #[serde(flatten)]
473 pub extra: serde_json::Map<String, serde_json::Value>,
474}
475
476#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
479pub struct InviteList {
480 #[serde(default)]
481 pub entries: Vec<InviteEntry>,
482 #[serde(default)]
483 pub tombstones: Vec<InviteTombstone>,
484 #[serde(flatten)]
485 pub extra: serde_json::Map<String, serde_json::Value>,
486}
487
488pub fn merge_invite_lists(a: InviteList, b: InviteList) -> InviteList {
493 use std::collections::BTreeMap;
494
495 let mut entries: BTreeMap<String, InviteEntry> = BTreeMap::new();
496 for e in a.entries.into_iter().chain(b.entries) {
497 entries.entry(e.token.clone()).or_insert(e);
498 }
499 let mut tombstones: BTreeMap<String, InviteTombstone> = BTreeMap::new();
500 for t in a.tombstones.into_iter().chain(b.tombstones) {
501 tombstones.entry(t.token.clone()).or_insert(t);
502 }
503 for token in tombstones.keys() {
504 entries.remove(token);
505 }
506 let mut extra = a.extra;
507 extra.extend(b.extra);
508 InviteList {
509 entries: entries.into_values().collect(),
510 tombstones: tombstones.into_values().collect(),
511 extra,
512 }
513}
514
515pub fn build_invite_list_event(my_keys: &Keys, list: &InviteList) -> Result<Event, InviteError> {
519 use nostr_sdk::prelude::nip44::{encrypt, Version};
520 let json = serde_json::to_string(list).map_err(|e| InviteError::Json(e.to_string()))?;
521 let content = encrypt(my_keys.secret_key(), &my_keys.public_key(), json.as_bytes(), Version::V2).map_err(|e| InviteError::Crypto(e.to_string()))?;
522 EventBuilder::new(Kind::Custom(kind::INVITE_LIST), content)
523 .finalize(my_keys)
524 .map_err(|e| InviteError::Crypto(e.to_string()))
525}
526
527pub fn parse_invite_list_event(event: &Event, my_keys: &Keys) -> Result<InviteList, InviteError> {
531 use nostr_sdk::prelude::nip44::decrypt;
532 if event.kind.as_u16() != kind::INVITE_LIST {
533 return Err(InviteError::BadEvent("not a kind-13303 invite list"));
534 }
535 let json = decrypt(my_keys.secret_key(), &my_keys.public_key(), &event.content).map_err(|e| InviteError::Crypto(e.to_string()))?;
536 serde_json::from_str(&json).map_err(|e| InviteError::Json(e.to_string()))
537}
538
539pub fn build_registry_content(signers: &[PublicKey]) -> String {
547 let hexes: Vec<String> = signers.iter().map(|p| p.to_hex()).collect();
548 serde_json::to_string(&hexes).expect("Vec<String> always serializes")
549}
550
551pub fn parse_registry_content(content: &str) -> Result<Vec<PublicKey>, InviteError> {
553 let hexes: Vec<String> = serde_json::from_str(content).map_err(|e| InviteError::Json(e.to_string()))?;
554 hexes
555 .iter()
556 .map(|h| PublicKey::from_hex(h).map_err(|_| InviteError::BadHex("registry signer")))
557 .collect()
558}
559
560pub fn build_direct_invite(
571 inviter_keys: &Keys,
572 recipient: &PublicKey,
573 bundle: &CommunityInvite,
574) -> Result<Event, InviteError> {
575 let json = serde_json::to_string(bundle).map_err(|e| InviteError::Json(e.to_string()))?;
576
577 let mut rumor = EventBuilder::new(Kind::Custom(kind::DIRECT_INVITE), json)
579 .custom_created_at(Timestamp::now())
580 .finalize_unsigned_with_id(inviter_keys.public_key());
581 rumor.ensure_id();
582
583 let seal_content = nip44::encrypt(inviter_keys.secret_key(), recipient, rumor.as_json(), nip44::Version::default())
584 .map_err(|e| InviteError::Crypto(e.to_string()))?;
585 let seal = EventBuilder::new(Kind::Seal, seal_content)
586 .custom_created_at(crate::sending::tweaked_timestamp())
587 .finalize(inviter_keys)
588 .map_err(|e| InviteError::Crypto(e.to_string()))?;
589
590 let ephemeral = Keys::generate();
591 let wrap_content = nip44::encrypt(ephemeral.secret_key(), recipient, seal.as_json(), nip44::Version::default())
592 .map_err(|e| InviteError::Crypto(e.to_string()))?;
593 let mut tags = vec![
594 Tag::public_key(*recipient),
595 Tag::custom("k", [kind::DIRECT_INVITE.to_string()]),
596 ];
597 if let Some(ms) = bundle.expires_at {
598 tags.push(Tag::custom("expiration", [(ms / 1000).to_string()]));
599 }
600 EventBuilder::new(Kind::GiftWrap, wrap_content)
601 .tags(tags)
602 .custom_created_at(crate::sending::tweaked_timestamp())
603 .finalize(&ephemeral)
604 .map_err(|e| InviteError::Crypto(e.to_string()))
605}
606
607pub fn unwrap_direct_invite(wrap: &Event, recipient_keys: &Keys) -> Result<(PublicKey, CommunityInvite), InviteError> {
614 if wrap.kind != Kind::GiftWrap {
615 return Err(InviteError::BadEvent("not a gift wrap"));
616 }
617 let seal_json = nip44::decrypt(recipient_keys.secret_key(), &wrap.pubkey, &wrap.content)
618 .map_err(|e| InviteError::Crypto(e.to_string()))?;
619 let seal = Event::from_json(&seal_json).map_err(|e| InviteError::Json(e.to_string()))?;
620 if seal.kind != Kind::Seal {
621 return Err(InviteError::BadEvent("inner is not a seal"));
622 }
623 seal.verify().map_err(|_| InviteError::BadEvent("seal signature invalid"))?;
624
625 let rumor_json = nip44::decrypt(recipient_keys.secret_key(), &seal.pubkey, &seal.content)
626 .map_err(|e| InviteError::Crypto(e.to_string()))?;
627 let rumor = UnsignedEvent::from_json(rumor_json.as_bytes()).map_err(|e| InviteError::Json(e.to_string()))?;
628 if rumor.kind.as_u16() != kind::DIRECT_INVITE {
629 return Err(InviteError::BadEvent("rumor is not a direct invite"));
630 }
631 if rumor.pubkey != seal.pubkey {
632 return Err(InviteError::BadEvent("rumor author does not match the seal signer"));
633 }
634 let bundle = CommunityInvite::from_bundle_json(&rumor.content)?;
635 Ok((seal.pubkey, bundle))
636}
637
638pub async fn build_invite_list_event_signed<S: crate::signer::VectorSigner + ?Sized>(
643 signer: &S,
644 my_pk: PublicKey,
645 list: &InviteList,
646) -> Result<Event, InviteError> {
647 let json = serde_json::to_string(list).map_err(|e| InviteError::Json(e.to_string()))?;
648 let content = signer.nip44_encrypt_async(&my_pk, &json).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
649 let unsigned = EventBuilder::new(Kind::Custom(kind::INVITE_LIST), content).finalize_unsigned_with_id(my_pk);
650 signer.sign_event_async(unsigned).await.map_err(|e| InviteError::Crypto(e.to_string()))
651}
652
653pub async fn parse_invite_list_event_signed<S: crate::signer::VectorSigner + ?Sized>(
655 signer: &S,
656 my_pk: PublicKey,
657 event: &Event,
658) -> Result<InviteList, InviteError> {
659 if event.kind.as_u16() != kind::INVITE_LIST {
660 return Err(InviteError::BadEvent("not a kind-13303 invite list"));
661 }
662 let json = signer.nip44_decrypt_async(&my_pk, &event.content).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
663 serde_json::from_str(&json).map_err(|e| InviteError::Json(e.to_string()))
664}
665
666pub async fn build_direct_invite_signed<S: crate::signer::VectorSigner + ?Sized>(
669 signer: &S,
670 inviter_pk: PublicKey,
671 recipient: &PublicKey,
672 bundle: &CommunityInvite,
673) -> Result<Event, InviteError> {
674 let json = serde_json::to_string(bundle).map_err(|e| InviteError::Json(e.to_string()))?;
675 let mut rumor = EventBuilder::new(Kind::Custom(kind::DIRECT_INVITE), json)
676 .custom_created_at(Timestamp::now())
677 .finalize_unsigned_with_id(inviter_pk);
678 rumor.ensure_id();
679 let rumor_json = rumor.as_json();
680 let seal_content = signer.nip44_encrypt_async(recipient, &rumor_json).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
681 let seal_unsigned = EventBuilder::new(Kind::Seal, seal_content)
682 .custom_created_at(crate::sending::tweaked_timestamp())
683 .finalize_unsigned_with_id(inviter_pk);
684 let seal = signer.sign_event_async(seal_unsigned).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
685 let ephemeral = Keys::generate();
686 let wrap_content = nip44::encrypt(ephemeral.secret_key(), recipient, seal.as_json(), nip44::Version::default())
687 .map_err(|e| InviteError::Crypto(e.to_string()))?;
688 let mut tags = vec![
689 Tag::public_key(*recipient),
690 Tag::custom("k", [kind::DIRECT_INVITE.to_string()]),
691 ];
692 if let Some(ms) = bundle.expires_at {
693 tags.push(Tag::custom("expiration", [(ms / 1000).to_string()]));
694 }
695 EventBuilder::new(Kind::GiftWrap, wrap_content)
696 .tags(tags)
697 .custom_created_at(crate::sending::tweaked_timestamp())
698 .finalize(&ephemeral)
699 .map_err(|e| InviteError::Crypto(e.to_string()))
700}
701
702pub async fn unwrap_direct_invite_signed<S: crate::signer::VectorSigner + ?Sized>(
705 signer: &S,
706 wrap: &Event,
707) -> Result<(PublicKey, CommunityInvite), InviteError> {
708 if wrap.kind != Kind::GiftWrap {
709 return Err(InviteError::BadEvent("not a gift wrap"));
710 }
711 let seal_json = signer.nip44_decrypt_async(&wrap.pubkey, &wrap.content).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
712 let seal = Event::from_json(&seal_json).map_err(|e| InviteError::Json(e.to_string()))?;
713 if seal.kind != Kind::Seal {
714 return Err(InviteError::BadEvent("inner is not a seal"));
715 }
716 seal.verify().map_err(|_| InviteError::BadEvent("seal signature invalid"))?;
717 let rumor_json = signer.nip44_decrypt_async(&seal.pubkey, &seal.content).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
718 let rumor = UnsignedEvent::from_json(rumor_json.as_bytes()).map_err(|e| InviteError::Json(e.to_string()))?;
719 if rumor.kind.as_u16() != kind::DIRECT_INVITE {
720 return Err(InviteError::BadEvent("rumor is not a direct invite"));
721 }
722 if rumor.pubkey != seal.pubkey {
723 return Err(InviteError::BadEvent("rumor author does not match the seal signer"));
724 }
725 let bundle = CommunityInvite::from_bundle_json(&rumor.content)?;
726 Ok((seal.pubkey, bundle))
727}
728
729fn hex32(s: &str, field: &'static str) -> Result<[u8; 32], InviteError> {
732 crate::simd::hex::hex_to_bytes_32_checked(s).ok_or(InviteError::BadHex(field))
733}
734
735fn dict_id(url: &str) -> Option<u8> {
736 RELAY_DICT.iter().position(|u| *u == url).map(|i| (i + 1) as u8)
737}
738
739fn dict_url(id: u8) -> Option<&'static str> {
740 RELAY_DICT.get((id as usize).checked_sub(1)?).copied()
741}
742
743fn d_empty() -> Tag {
744 Tag::custom("d", [""])
745}
746
747fn vsk_tag(value: &str) -> Tag {
748 Tag::custom("vsk", [value])
749}
750
751fn first_tag(event: &Event, name: &str) -> Option<String> {
752 event.tags.iter().find_map(|t| {
753 let s = t.as_slice();
754 (s.len() >= 2 && s[0] == name).then(|| s[1].clone())
755 })
756}
757
758pub(crate) fn seal_bundle(bundle_key: &[u8; 32], json: &str) -> Result<String, InviteError> {
762 let ck = ConversationKey::new(*bundle_key);
763 let ct = crate::community::cipher::encrypt_with_random_nonce(&ck, json.as_bytes()).map_err(|e| InviteError::Crypto(e.to_string()))?;
764 Ok(base64_simd::STANDARD.encode_to_string(&ct))
765}
766
767fn open_bundle(bundle_key: &[u8; 32], content: &str) -> Result<String, InviteError> {
768 let ck = ConversationKey::new(*bundle_key);
769 let ct = base64_simd::STANDARD
770 .decode_to_vec(content.as_bytes())
771 .map_err(|e| InviteError::Crypto(e.to_string()))?;
772 let pt = decrypt_to_bytes(&ck, &ct).map_err(|e| InviteError::Crypto(e.to_string()))?;
773 String::from_utf8(pt).map_err(|e| InviteError::Crypto(e.to_string()))
774}
775
776fn signer_from_naddr(naddr: &str) -> Result<PublicKey, InviteError> {
777 let trimmed = naddr.trim().trim_start_matches("nostr:");
778 let n19 = nostr_sdk::prelude::nip19::Nip19::from_bech32(trimmed).map_err(|_| InviteError::BadLink("invalid naddr"))?;
779 match n19 {
780 nostr_sdk::prelude::nip19::Nip19::Coordinate(c)
781 if c.coordinate.kind.as_u16() == kind::INVITE_BUNDLE && c.coordinate.identifier.is_empty() =>
782 {
783 Ok(c.coordinate.public_key)
784 }
785 _ => Err(InviteError::BadLink("naddr is not an invite-bundle coordinate")),
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use super::super::control::CommunityIdentity;
792 use super::super::derive::invite_bundle_key;
793 use super::*;
794 use crate::community::{random_32, MAX_COMMUNITY_RELAYS};
795
796 fn hex(bytes: &[u8; 32]) -> String {
797 crate::simd::hex::bytes_to_hex_32(bytes)
798 }
799
800 #[test]
801 fn a_bundle_vending_no_channel_keys_omits_the_field_entirely() {
802 let json = r#"{
806 "community_id":"aa","owner":"bb","owner_salt":"cc",
807 "community_root":"dd","root_epoch":0,"name":"Public Room"
808 }"#;
809 let inv: CommunityInvite = serde_json::from_str(json).expect("absent `channels`/`relays` must parse");
810 assert!(inv.channels.is_empty());
811 assert!(inv.relays.is_empty());
812 assert_eq!(inv.icon, None, "absent optionals are None, not an error");
813 assert_eq!(inv.expires_at, None);
814 assert_eq!(inv.name, "Public Room");
815 }
816
817 fn valid_bundle() -> (Keys, CommunityInvite) {
819 let owner = Keys::generate();
820 let id = CommunityIdentity::mint(&owner.public_key());
821 let bundle = CommunityInvite {
822 community_id: hex(&id.community_id.0),
823 owner: hex(&id.owner_xonly),
824 owner_salt: hex(&id.owner_salt),
825 community_root: hex(&random_32()),
826 root_epoch: 0,
827 channels: vec![],
828 relays: vec!["wss://a.example".into(), "wss://b.example".into()],
829 name: "Test community".into(),
830 icon: None,
831 expires_at: None,
832 creator_npub: None,
833 label: None,
834 extra: Default::default(),
835 };
836 (owner, bundle)
837 }
838
839 fn token16() -> [u8; TOKEN_LEN] {
840 std::array::from_fn(|i| i as u8) }
842
843 #[test]
846 fn validate_accepts_a_correct_owner_salt_id_triple() {
847 let (_owner, bundle) = valid_bundle();
848 assert!(bundle.validate().is_ok());
849 }
850
851 #[test]
852 fn validate_rejects_a_forged_owner() {
853 let (_owner, mut bundle) = valid_bundle();
854 let attacker = Keys::generate();
856 bundle.owner = hex(&attacker.public_key().to_bytes());
857 assert!(matches!(bundle.validate(), Err(InviteError::OwnerMismatch)));
858 }
859
860 #[test]
861 fn from_json_rejects_over_the_channel_cap() {
862 let (_owner, mut bundle) = valid_bundle();
863 bundle.channels = (0..MAX_BUNDLE_CHANNELS + 1)
864 .map(|_| ChannelGrant { id: hex(&random_32()), key: hex(&random_32()), epoch: 0, name: "x".into() })
865 .collect();
866 let json = serde_json::to_string(&bundle).unwrap();
867 assert!(matches!(
868 CommunityInvite::from_bundle_json(&json),
869 Err(InviteError::TooManyChannels(n)) if n == MAX_BUNDLE_CHANNELS + 1
870 ));
871 }
872
873 #[test]
874 fn from_json_truncates_relays_to_the_community_cap() {
875 let (_owner, mut bundle) = valid_bundle();
876 bundle.relays = (0..MAX_COMMUNITY_RELAYS + 3).map(|i| format!("wss://r{i}.example")).collect();
877 let json = serde_json::to_string(&bundle).unwrap();
878 let parsed = CommunityInvite::from_bundle_json(&json).unwrap();
879 assert_eq!(parsed.relays.len(), MAX_COMMUNITY_RELAYS);
880 }
881
882 #[test]
883 fn unknown_bundle_fields_round_trip() {
884 let (_owner, bundle) = valid_bundle();
887 let mut value = serde_json::to_value(&bundle).unwrap();
888 let obj = value.as_object_mut().unwrap();
889 obj.insert("held_roots".into(), serde_json::json!([{ "epoch": 2, "key": "ab" }]));
890 obj.insert("refounder".into(), serde_json::json!("deadbeef"));
891 obj.insert("future_field".into(), serde_json::json!({ "deep": [1, 2] }));
892 let json = serde_json::to_string(&value).unwrap();
893
894 let parsed = CommunityInvite::from_bundle_json(&json).unwrap();
895 let out: serde_json::Value = serde_json::from_str(&serde_json::to_string(&parsed).unwrap()).unwrap();
896 assert_eq!(out["held_roots"][0]["epoch"], 2);
897 assert_eq!(out["refounder"], "deadbeef");
898 assert_eq!(out["future_field"]["deep"][1], 2);
899 }
900
901 #[test]
902 fn expiry_is_a_join_gate_not_a_render_gate() {
903 let (_owner, mut bundle) = valid_bundle();
904 bundle.expires_at = Some(1_000_000);
905 assert!(!bundle.expired(999_999));
906 assert!(bundle.expired(1_000_001));
907 }
908
909 #[test]
912 fn fragment_golden_stock_set() {
913 let token = token16();
917 let frag = encode_fragment(&token, &stock_relays()).unwrap();
918 assert_eq!(frag, "BAEAAQIDBAUGBwgJCgsMDQ4P");
919 let (t, relays) = decode_fragment(&frag).unwrap();
920 assert_eq!(t, token);
921 assert_eq!(relays, stock_relays());
922 }
923
924 #[test]
925 fn fragment_golden_dictionary_id_mix() {
926 let token = token16();
930 let relays = vec![RELAY_DICT[1].to_string(), RELAY_DICT[3].to_string()];
931 let frag = encode_fragment(&token, &relays).unwrap();
932 assert_eq!(frag, "BAACAgQAAQIDBAUGBwgJCgsMDQ4P");
933 let (t, out) = decode_fragment(&frag).unwrap();
934 assert_eq!(t, token);
935 assert_eq!(out, relays);
936 }
937
938 #[test]
939 fn fragment_golden_wss_implied_literal() {
940 let token = token16();
944 let relays = vec!["wss://x.y".to_string()];
945 let frag = encode_fragment(&token, &relays).unwrap();
946 assert_eq!(frag, "BAABAAN4LnkAAQIDBAUGBwgJCgsMDQ4P");
947 let (t, out) = decode_fragment(&frag).unwrap();
948 assert_eq!(t, token);
949 assert_eq!(out, relays);
950 }
951
952 #[test]
953 fn fragment_golden_verbatim_literal() {
954 let token = token16();
958 let relays = vec!["ws://h".to_string()];
959 let frag = encode_fragment(&token, &relays).unwrap();
960 assert_eq!(frag, "BAAB_wZ3czovL2gAAQIDBAUGBwgJCgsMDQ4P");
961 let (t, out) = decode_fragment(&frag).unwrap();
962 assert_eq!(t, token);
963 assert_eq!(out, relays);
964 }
965
966 #[test]
967 fn fragment_rejects_wrong_version_both_directions() {
968 let token = token16();
969 for bad in [3u8, 5u8] {
970 let mut bytes = vec![bad, FLAG_STOCK_SET];
971 bytes.extend_from_slice(&token);
972 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
973 assert!(matches!(decode_fragment(&frag), Err(InviteError::BadVersion(v)) if v == bad));
974 }
975 }
976
977 #[test]
978 fn fragment_rejects_trailing_bytes() {
979 let token = token16();
980 let mut bytes = vec![FRAGMENT_VERSION, FLAG_STOCK_SET];
981 bytes.extend_from_slice(&token);
982 bytes.push(0xff); let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
984 assert!(matches!(decode_fragment(&frag), Err(InviteError::BadFragment("trailing bytes"))));
985 }
986
987 #[test]
988 fn fragment_rejects_count_over_three() {
989 let token = token16();
990 let mut bytes = vec![FRAGMENT_VERSION, 0x00, 0x04]; bytes.extend_from_slice(&[1, 2, 3, 4]);
992 bytes.extend_from_slice(&token);
993 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
994 assert!(matches!(decode_fragment(&frag), Err(InviteError::BadFragment("too many bootstrap relays"))));
995 }
996
997 #[test]
998 fn fragment_skips_an_unknown_dictionary_id() {
999 let token = token16();
1000 let mut bytes = vec![FRAGMENT_VERSION, 0x00, 0x01, 200];
1002 bytes.extend_from_slice(&token);
1003 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
1004 let (t, relays) = decode_fragment(&frag).unwrap();
1005 assert_eq!(t, token);
1006 assert!(relays.is_empty());
1007 }
1008
1009 #[test]
1010 fn fragment_caps_bootstrap_relays_at_three() {
1011 let token = token16();
1012 let relays: Vec<String> = (0..4).map(|i| format!("wss://r{i}.example")).collect();
1013 let (_t, out) = decode_fragment(&encode_fragment(&token, &relays).unwrap()).unwrap();
1014 assert_eq!(out.len(), MAX_BOOTSTRAP_RELAYS);
1015 }
1016
1017 #[test]
1020 fn bundle_event_round_trips_live() {
1021 let (_owner, bundle) = valid_bundle();
1022 let link = Keys::generate();
1023 let token = random_32();
1024 let key = invite_bundle_key(&token[..TOKEN_LEN].try_into().unwrap());
1025 let event = build_bundle_event(&link, &bundle, &key).unwrap();
1026 assert_eq!(event.pubkey, link.public_key());
1027
1028 match parse_bundle_event(&event, &link.public_key(), &key).unwrap() {
1029 BundleState::Live(b) => {
1030 assert_eq!(b.community_id, bundle.community_id);
1031 assert_eq!(b.name, "Test community");
1032 }
1033 BundleState::Revoked => panic!("expected Live"),
1034 }
1035 }
1036
1037 #[test]
1038 fn revocation_reads_as_revoked() {
1039 let link = Keys::generate();
1040 let tomb = build_revocation(&link).unwrap();
1041 let key = invite_bundle_key(&[0u8; TOKEN_LEN]);
1042 assert!(matches!(parse_bundle_event(&tomb, &link.public_key(), &key), Ok(BundleState::Revoked)));
1043 }
1044
1045 #[test]
1046 fn bundle_event_wrong_key_fails() {
1047 let (_owner, bundle) = valid_bundle();
1048 let link = Keys::generate();
1049 let key = invite_bundle_key(&[7u8; TOKEN_LEN]);
1050 let event = build_bundle_event(&link, &bundle, &key).unwrap();
1051 let wrong = invite_bundle_key(&[8u8; TOKEN_LEN]);
1052 assert!(parse_bundle_event(&event, &link.public_key(), &wrong).is_err());
1053 }
1054
1055 #[test]
1056 fn bundle_event_from_a_different_author_is_rejected() {
1057 let (_owner, bundle) = valid_bundle();
1060 let real = Keys::generate();
1061 let squatter = Keys::generate();
1062 let key = invite_bundle_key(&[3u8; TOKEN_LEN]);
1063 let event = build_bundle_event(&squatter, &bundle, &key).unwrap();
1064 assert!(matches!(
1065 parse_bundle_event(&event, &real.public_key(), &key),
1066 Err(InviteError::BadEvent("author is not the link signer"))
1067 ));
1068 }
1069
1070 #[test]
1074 fn bundle_event_at_a_non_empty_d_is_rejected() {
1075 let (_owner, bundle) = valid_bundle();
1076 let link = Keys::generate();
1077 let key = invite_bundle_key(&[9u8; TOKEN_LEN]);
1078 let json = serde_json::to_string(&bundle).unwrap();
1079 let content = seal_bundle(&key, &json).unwrap();
1080 let event = EventBuilder::new(Kind::Custom(kind::INVITE_BUNDLE), content)
1081 .tags([Tag::identifier("elsewhere"), vsk_tag(vsk::INVITE_LIVE)])
1082 .finalize(&link)
1083 .unwrap();
1084 assert!(matches!(
1085 parse_bundle_event(&event, &link.public_key(), &key),
1086 Err(InviteError::BadEvent("bundle is not at the link's coordinate"))
1087 ));
1088 }
1089
1090 #[test]
1091 fn bundle_event_with_a_tampered_signature_is_rejected() {
1092 let (_owner, bundle) = valid_bundle();
1093 let link = Keys::generate();
1094 let other = Keys::generate();
1095 let key = invite_bundle_key(&[4u8; TOKEN_LEN]);
1096 let event = build_bundle_event(&link, &bundle, &key).unwrap();
1097 let mut json: serde_json::Value = serde_json::from_str(&event.as_json()).unwrap();
1099 json["pubkey"] = serde_json::Value::String(other.public_key().to_hex());
1100 let Ok(forged) = Event::from_json(json.to_string()) else { return };
1101 assert!(matches!(
1102 parse_bundle_event(&forged, &other.public_key(), &key),
1103 Err(InviteError::BadEvent("signature invalid"))
1104 ));
1105 }
1106
1107 fn entry(token: &str, community: &str) -> InviteEntry {
1110 InviteEntry {
1111 token: token.into(),
1112 signer_sk: "bb".repeat(32),
1113 community_id: community.into(),
1114 url: "https://x/invite/naddr1xyz#frag".into(),
1115 label: None,
1116 created_at: 1000,
1117 expires_at: None,
1118 extra: Default::default(),
1119 }
1120 }
1121
1122 #[test]
1123 fn invite_list_merge_entries_immutable_tombstone_wins_terminally() {
1124 let e = entry(&"aa".repeat(16), &"cc".repeat(32));
1125 let a = merge_invite_lists(InviteList::default(), InviteList { entries: vec![e.clone()], ..Default::default() });
1126 assert_eq!(a.entries.len(), 1);
1127
1128 let mut mutated = e.clone();
1130 mutated.label = Some("changed".into());
1131 let still = merge_invite_lists(a.clone(), InviteList { entries: vec![mutated], ..Default::default() });
1132 assert_eq!(still.entries[0].label, None);
1133
1134 let tomb = InviteTombstone { token: e.token.clone(), community_id: e.community_id.clone(), extra: Default::default() };
1136 let b = merge_invite_lists(a, InviteList { tombstones: vec![tomb], ..Default::default() });
1137 assert!(b.entries.is_empty());
1138 assert_eq!(b.tombstones.len(), 1);
1139
1140 let c = merge_invite_lists(b, InviteList { entries: vec![e], ..Default::default() });
1142 assert!(c.entries.is_empty());
1143 }
1144
1145 #[test]
1146 fn invite_list_tombstones_union() {
1147 let t1 = InviteTombstone { token: "11".repeat(16), community_id: "aa".repeat(32), extra: Default::default() };
1148 let t2 = InviteTombstone { token: "22".repeat(16), community_id: "bb".repeat(32), extra: Default::default() };
1149 let merged = merge_invite_lists(
1150 InviteList { tombstones: vec![t1], ..Default::default() },
1151 InviteList { tombstones: vec![t2], ..Default::default() },
1152 );
1153 assert_eq!(merged.tombstones.len(), 2);
1154 }
1155
1156 #[test]
1157 fn invite_list_unknown_fields_round_trip() {
1158 let wire = r#"{"entries":[],"tombstones":[],"schema":"future","note":{"x":1}}"#;
1159 let list: InviteList = serde_json::from_str(wire).unwrap();
1160 let out: serde_json::Value = serde_json::from_str(&serde_json::to_string(&list).unwrap()).unwrap();
1161 assert_eq!(out["schema"], "future");
1162 assert_eq!(out["note"]["x"], 1);
1163 }
1164
1165 #[test]
1168 fn direct_invite_round_trips_to_inviter_and_bundle() {
1169 let inviter = Keys::generate();
1170 let recipient = Keys::generate();
1171 let (_owner, bundle) = valid_bundle();
1172
1173 let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1174 assert_eq!(wrap.kind, Kind::GiftWrap);
1175 assert_ne!(wrap.pubkey, inviter.public_key(), "wrap author is ephemeral");
1176 assert!(wrap.tags.iter().any(|t| {
1177 let s = t.as_slice();
1178 s.len() >= 2 && s[0] == "k" && s[1] == kind::DIRECT_INVITE.to_string()
1179 }));
1180
1181 let (sender, out) = unwrap_direct_invite(&wrap, &recipient).unwrap();
1182 assert_eq!(sender, inviter.public_key());
1183 assert_eq!(out.community_id, bundle.community_id);
1184 assert_eq!(out.name, "Test community");
1185 }
1186
1187 #[test]
1188 fn direct_invite_rejects_a_seal_claiming_a_false_inviter() {
1189 let inviter = Keys::generate();
1192 let claimed = Keys::generate();
1193 let recipient = Keys::generate();
1194 let (_owner, bundle) = valid_bundle();
1195
1196 let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1198 let seal_json = nip44::decrypt(recipient.secret_key(), &wrap.pubkey, &wrap.content).unwrap();
1199 let mut seal_val: serde_json::Value = serde_json::from_str(&seal_json).unwrap();
1200 seal_val["pubkey"] = serde_json::Value::String(claimed.public_key().to_hex());
1201 let ephemeral = Keys::generate();
1203 let wrap_ct =
1204 nip44::encrypt(ephemeral.secret_key(), &recipient.public_key(), seal_val.to_string(), nip44::Version::default())
1205 .unwrap();
1206 let forged = EventBuilder::new(Kind::GiftWrap, wrap_ct)
1207 .tags([Tag::public_key(recipient.public_key())])
1208 .finalize(&ephemeral)
1209 .unwrap();
1210 assert!(unwrap_direct_invite(&forged, &recipient).is_err());
1211 }
1212
1213 #[test]
1214 fn direct_invite_rejects_a_non_invite_rumor_kind() {
1215 let inviter = Keys::generate();
1216 let recipient = Keys::generate();
1217 let (_owner, bundle) = valid_bundle();
1218 let json = serde_json::to_string(&bundle).unwrap();
1219
1220 let mut rumor = EventBuilder::new(Kind::Custom(9), json)
1222 .custom_created_at(Timestamp::now())
1223 .finalize_unsigned_with_id(inviter.public_key());
1224 rumor.ensure_id();
1225 let seal_ct =
1226 nip44::encrypt(inviter.secret_key(), &recipient.public_key(), rumor.as_json(), nip44::Version::default()).unwrap();
1227 let seal = EventBuilder::new(Kind::Seal, seal_ct).finalize(&inviter).unwrap();
1228 let ephemeral = Keys::generate();
1229 let wrap_ct =
1230 nip44::encrypt(ephemeral.secret_key(), &recipient.public_key(), seal.as_json(), nip44::Version::default()).unwrap();
1231 let wrap = EventBuilder::new(Kind::GiftWrap, wrap_ct)
1232 .tags([Tag::public_key(recipient.public_key())])
1233 .finalize(&ephemeral)
1234 .unwrap();
1235 assert!(matches!(
1236 unwrap_direct_invite(&wrap, &recipient),
1237 Err(InviteError::BadEvent("rumor is not a direct invite"))
1238 ));
1239 }
1240
1241 #[test]
1242 fn direct_invite_stamps_nip40_expiration_in_seconds() {
1243 let inviter = Keys::generate();
1244 let recipient = Keys::generate();
1245 let (_owner, mut bundle) = valid_bundle();
1246 let expires_ms = 1_735_689_600_000u64;
1247 bundle.expires_at = Some(expires_ms);
1248
1249 let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1250 let exp = wrap.tags.iter().find_map(|t| {
1251 let s = t.as_slice();
1252 (s.len() >= 2 && s[0] == "expiration").then(|| s[1].clone())
1253 });
1254 assert_eq!(exp, Some((expires_ms / 1000).to_string()));
1255 }
1256
1257 #[test]
1258 fn direct_invite_rejects_a_forged_owner_bundle() {
1259 let inviter = Keys::generate();
1260 let recipient = Keys::generate();
1261 let (_owner, mut bundle) = valid_bundle();
1262 bundle.owner = hex(&Keys::generate().public_key().to_bytes()); let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1264 assert!(matches!(unwrap_direct_invite(&wrap, &recipient), Err(InviteError::OwnerMismatch)));
1265 }
1266
1267 #[test]
1270 fn invite_link_round_trips_full_url_and_bare_form() {
1271 let link = Keys::generate();
1272 let token = token16();
1273 let relays = vec!["wss://a.example".to_string()];
1274 let url = build_invite_url("https://vectorapp.io", &link.public_key(), &token, &relays).unwrap();
1275 assert!(url.contains("/invite/naddr1"));
1276
1277 let parsed = parse_invite_link(&url).unwrap();
1278 assert_eq!(parsed.link_signer, link.public_key());
1279 assert_eq!(parsed.token, token);
1280 assert_eq!(parsed.bootstrap_relays, relays);
1281
1282 let bare = format!("{}#{}", parsed.naddr, url.split('#').nth(1).unwrap());
1283 assert_eq!(parse_invite_link(&bare).unwrap().link_signer, link.public_key());
1284 }
1285
1286 #[test]
1287 fn invite_link_rejects_non_invites() {
1288 assert!(parse_invite_link("hello world").is_err());
1289 assert!(parse_invite_link("wss://relay.example.com").is_err());
1290 assert!(parse_invite_link("https://x/invite/#frag").is_err());
1291 }
1292
1293 #[test]
1294 fn v2_parser_rejects_a_v1_style_url_no_cross_protocol_confusion() {
1295 let v1_url = "https://vectorapp.io/invite#AmR1bW15djFmcmFnbWVudA";
1301 assert!(parse_invite_link(v1_url).is_err(), "v2 must reject a v1-format invite URL");
1302 assert!(parse_invite_link("https://vectorapp.io/invite/#AmR1bW15").is_err());
1304 let token = [0x07u8; TOKEN_LEN];
1306 let signer = Keys::generate();
1307 let naddr = bundle_naddr(&signer.public_key()).unwrap();
1308 let frag = encode_fragment(&token, &[]).unwrap();
1309 assert!(parse_invite_link(&format!("{naddr}#{frag}")).is_ok());
1310 }
1311
1312 #[test]
1315 fn registry_content_round_trips_and_leaks_no_secrets() {
1316 let a = Keys::generate();
1317 let b = Keys::generate();
1318 let content = build_registry_content(&[a.public_key(), b.public_key()]);
1319
1320 let arr: Vec<String> = serde_json::from_str(&content).unwrap();
1322 assert_eq!(arr, vec![a.public_key().to_hex(), b.public_key().to_hex()]);
1323 assert!(!content.contains("invite"));
1325 assert!(!content.contains("token"));
1326 assert!(!content.contains("://"));
1327
1328 let back = parse_registry_content(&content).unwrap();
1329 assert_eq!(back, vec![a.public_key(), b.public_key()]);
1330 }
1331}