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 pub channels: Vec<ChannelGrant>,
142 pub relays: Vec<String>,
143 pub name: String,
145 #[serde(skip_serializing_if = "Option::is_none")]
146 pub icon: Option<ImageRef>,
147 #[serde(skip_serializing_if = "Option::is_none")]
149 pub expires_at: Option<u64>,
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub creator_npub: Option<String>,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 pub label: Option<String>,
155 #[serde(flatten)]
158 pub extra: serde_json::Map<String, serde_json::Value>,
159}
160
161impl CommunityInvite {
162 pub fn from_bundle_json(json: &str) -> Result<Self, InviteError> {
166 let mut bundle: CommunityInvite = serde_json::from_str(json).map_err(|e| InviteError::Json(e.to_string()))?;
167 if bundle.channels.len() > MAX_BUNDLE_CHANNELS {
168 return Err(InviteError::TooManyChannels(bundle.channels.len()));
169 }
170 bundle.relays = cap_relays(std::mem::take(&mut bundle.relays));
171 bundle.validate()?;
172 Ok(bundle)
173 }
174
175 pub fn validate(&self) -> Result<(), InviteError> {
179 if self.channels.len() > MAX_BUNDLE_CHANNELS {
180 return Err(InviteError::TooManyChannels(self.channels.len()));
181 }
182 if self.root_epoch > MAX_BUNDLE_EPOCH || self.channels.iter().any(|c| c.epoch > MAX_BUNDLE_EPOCH) {
187 return Err(InviteError::BadFragment("epoch out of range"));
188 }
189 let owner = hex32(&self.owner, "owner")?;
190 let salt = hex32(&self.owner_salt, "owner_salt")?;
191 let cid = hex32(&self.community_id, "community_id")?;
192 if !verify_community_id(&CommunityId(cid), &owner, &salt) {
193 return Err(InviteError::OwnerMismatch);
194 }
195 Ok(())
196 }
197
198 pub fn expired(&self, now_ms: u64) -> bool {
202 self.expires_at.is_some_and(|e| now_ms > e)
203 }
204}
205
206#[derive(Debug)]
210pub enum BundleState {
211 Live(Box<CommunityInvite>),
213 Revoked,
215}
216
217pub fn build_bundle_event(
222 link_signer: &Keys,
223 bundle: &CommunityInvite,
224 bundle_key: &[u8; 32],
225) -> Result<Event, InviteError> {
226 let json = serde_json::to_string(bundle).map_err(|e| InviteError::Json(e.to_string()))?;
227 let content = seal_bundle(bundle_key, &json)?;
228 EventBuilder::new(Kind::Custom(kind::INVITE_BUNDLE), content)
229 .tags([d_empty(), vsk_tag(vsk::INVITE_LIVE)])
230 .finalize(link_signer)
231 .map_err(|e| InviteError::Crypto(e.to_string()))
232}
233
234pub fn build_revocation(link_signer: &Keys) -> Result<Event, InviteError> {
238 EventBuilder::new(Kind::Custom(kind::INVITE_BUNDLE), "")
239 .tags([d_empty(), vsk_tag(vsk::INVITE_REVOKED)])
240 .finalize(link_signer)
241 .map_err(|e| InviteError::Crypto(e.to_string()))
242}
243
244pub fn parse_bundle_event(
251 event: &Event,
252 expected_signer: &PublicKey,
253 bundle_key: &[u8; 32],
254) -> Result<BundleState, InviteError> {
255 if event.kind.as_u16() != kind::INVITE_BUNDLE {
256 return Err(InviteError::BadEvent("wrong kind"));
257 }
258 if event.pubkey != *expected_signer {
259 return Err(InviteError::BadEvent("author is not the link signer"));
260 }
261 if !first_tag(event, "d").unwrap_or_default().is_empty() {
264 return Err(InviteError::BadEvent("bundle is not at the link's coordinate"));
265 }
266 event.verify().map_err(|_| InviteError::BadEvent("signature invalid"))?;
267
268 match first_tag(event, "vsk").as_deref() {
269 Some(v) if v == vsk::INVITE_REVOKED => return Ok(BundleState::Revoked),
270 Some(v) if v == vsk::INVITE_LIVE => {}
271 _ => return Err(InviteError::BadEvent("unknown or missing bundle marker")),
272 }
273
274 let json = open_bundle(bundle_key, &event.content)?;
275 Ok(BundleState::Live(Box::new(CommunityInvite::from_bundle_json(&json)?)))
276}
277
278const INVITE_PATH: &str = "/invite/";
281
282#[derive(Debug, Clone)]
284pub struct ParsedInviteLink {
285 pub link_signer: PublicKey,
287 pub token: [u8; TOKEN_LEN],
288 pub bootstrap_relays: Vec<String>,
289 pub naddr: String,
291}
292
293pub fn stock_relays() -> Vec<String> {
296 RELAY_DICT.iter().map(|s| s.to_string()).collect()
297}
298
299pub fn encode_fragment(token: &[u8; TOKEN_LEN], relays: &[String]) -> Result<String, InviteError> {
305 let is_stock = relays.len() == RELAY_DICT.len() && relays.iter().zip(RELAY_DICT.iter()).all(|(r, d)| r == d);
306
307 let mut bytes = Vec::with_capacity(2 + TOKEN_LEN + relays.len() * 8);
308 bytes.push(FRAGMENT_VERSION);
309 if is_stock {
310 bytes.push(FLAG_STOCK_SET);
311 } else {
312 bytes.push(0x00);
313 let bounded = &relays[..relays.len().min(MAX_BOOTSTRAP_RELAYS)];
314 bytes.push(bounded.len() as u8);
315 for relay in bounded {
316 if let Some(id) = dict_id(relay) {
317 bytes.push(id);
318 } else if let Some(host) = relay.strip_prefix("wss://") {
319 if host.len() > u8::MAX as usize {
320 return Err(InviteError::BadFragment("relay host too long"));
321 }
322 bytes.extend_from_slice(&[0x00, host.len() as u8]);
323 bytes.extend_from_slice(host.as_bytes());
324 } else {
325 if relay.len() > u8::MAX as usize {
326 return Err(InviteError::BadFragment("relay url too long"));
327 }
328 bytes.extend_from_slice(&[0xff, relay.len() as u8]);
329 bytes.extend_from_slice(relay.as_bytes());
330 }
331 }
332 }
333 bytes.extend_from_slice(token);
334 Ok(base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes))
335}
336
337pub fn decode_fragment(fragment: &str) -> Result<([u8; TOKEN_LEN], Vec<String>), InviteError> {
342 let bytes = base64_simd::URL_SAFE_NO_PAD
343 .decode_to_vec(fragment.trim().as_bytes())
344 .map_err(|_| InviteError::BadFragment("not base64url"))?;
345
346 if bytes.len() < 2 {
347 return Err(InviteError::BadFragment("truncated"));
348 }
349 let version = bytes[0];
350 if version != FRAGMENT_VERSION {
353 return Err(InviteError::BadVersion(version));
354 }
355 let flags = bytes[1];
356 let mut o = 2usize;
357
358 let mut relays = Vec::new();
359 if flags & FLAG_STOCK_SET != 0 {
360 relays = stock_relays();
361 } else {
362 let count = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))? as usize;
363 o += 1;
364 if count > MAX_BOOTSTRAP_RELAYS {
365 return Err(InviteError::BadFragment("too many bootstrap relays"));
366 }
367 for _ in 0..count {
368 let lead = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))?;
369 o += 1;
370 if (1..=254).contains(&lead) {
371 if let Some(url) = dict_url(lead) {
372 relays.push(url.to_string());
373 }
374 } else {
376 let len = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))? as usize;
377 o += 1;
378 let end = o.checked_add(len).ok_or(InviteError::BadFragment("truncated"))?;
379 let raw = bytes.get(o..end).ok_or(InviteError::BadFragment("truncated"))?;
380 let text = std::str::from_utf8(raw).map_err(|_| InviteError::BadFragment("relay not utf8"))?;
381 relays.push(if lead == 255 { text.to_string() } else { format!("wss://{text}") });
382 o = end;
383 }
384 }
385 }
386
387 let end = o.checked_add(TOKEN_LEN).ok_or(InviteError::BadFragment("truncated"))?;
388 let raw = bytes.get(o..end).ok_or(InviteError::BadFragment("truncated"))?;
389 let mut token = [0u8; TOKEN_LEN];
390 token.copy_from_slice(raw);
391 if end != bytes.len() {
392 return Err(InviteError::BadFragment("trailing bytes"));
393 }
394 Ok((token, relays))
395}
396
397pub fn bundle_naddr(link_signer: &PublicKey) -> Result<String, InviteError> {
401 let coord = nostr_sdk::prelude::nip01::Coordinate {
402 kind: Kind::Custom(kind::INVITE_BUNDLE),
403 public_key: *link_signer,
404 identifier: String::new(),
405 };
406 let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate { coordinate: coord, relays: Vec::new() };
407 nostr_sdk::prelude::nip19::Nip19::Coordinate(n19)
408 .to_bech32()
409 .map_err(|e| InviteError::Crypto(e.to_string()))
410}
411
412pub fn build_invite_url(
415 base: &str,
416 link_signer: &PublicKey,
417 token: &[u8; TOKEN_LEN],
418 relays: &[String],
419) -> Result<String, InviteError> {
420 let naddr = bundle_naddr(link_signer)?;
421 let fragment = encode_fragment(token, relays)?;
422 Ok(format!("{}{INVITE_PATH}{naddr}#{fragment}", base.trim_end_matches('/')))
423}
424
425pub fn parse_invite_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
428 let (locator, fragment) = input.trim().split_once('#').ok_or(InviteError::BadLink("no fragment"))?;
429 if fragment.is_empty() {
430 return Err(InviteError::BadLink("empty fragment"));
431 }
432 let naddr = match locator.find(INVITE_PATH) {
434 Some(i) => locator[i + INVITE_PATH.len()..].trim_end_matches('/'),
435 None => locator.trim_start_matches("nostr:"),
436 };
437 let link_signer = signer_from_naddr(naddr)?;
438 let (token, bootstrap_relays) = decode_fragment(fragment)?;
439 Ok(ParsedInviteLink { link_signer, token, bootstrap_relays, naddr: naddr.to_string() })
440}
441
442#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
448pub struct InviteEntry {
449 pub token: String,
450 pub signer_sk: String,
451 pub community_id: String,
452 pub url: String,
453 #[serde(skip_serializing_if = "Option::is_none")]
454 pub label: Option<String>,
455 pub created_at: u64,
456 #[serde(skip_serializing_if = "Option::is_none")]
457 pub expires_at: Option<u64>,
458 #[serde(flatten)]
459 pub extra: serde_json::Map<String, serde_json::Value>,
460}
461
462#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
464pub struct InviteTombstone {
465 pub token: String,
466 pub community_id: String,
467 #[serde(flatten)]
468 pub extra: serde_json::Map<String, serde_json::Value>,
469}
470
471#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
474pub struct InviteList {
475 #[serde(default)]
476 pub entries: Vec<InviteEntry>,
477 #[serde(default)]
478 pub tombstones: Vec<InviteTombstone>,
479 #[serde(flatten)]
480 pub extra: serde_json::Map<String, serde_json::Value>,
481}
482
483pub fn merge_invite_lists(a: InviteList, b: InviteList) -> InviteList {
488 use std::collections::BTreeMap;
489
490 let mut entries: BTreeMap<String, InviteEntry> = BTreeMap::new();
491 for e in a.entries.into_iter().chain(b.entries) {
492 entries.entry(e.token.clone()).or_insert(e);
493 }
494 let mut tombstones: BTreeMap<String, InviteTombstone> = BTreeMap::new();
495 for t in a.tombstones.into_iter().chain(b.tombstones) {
496 tombstones.entry(t.token.clone()).or_insert(t);
497 }
498 for token in tombstones.keys() {
499 entries.remove(token);
500 }
501 let mut extra = a.extra;
502 extra.extend(b.extra);
503 InviteList {
504 entries: entries.into_values().collect(),
505 tombstones: tombstones.into_values().collect(),
506 extra,
507 }
508}
509
510pub fn build_invite_list_event(my_keys: &Keys, list: &InviteList) -> Result<Event, InviteError> {
514 use nostr_sdk::prelude::nip44::{encrypt, Version};
515 let json = serde_json::to_string(list).map_err(|e| InviteError::Json(e.to_string()))?;
516 let content = encrypt(my_keys.secret_key(), &my_keys.public_key(), json.as_bytes(), Version::V2).map_err(|e| InviteError::Crypto(e.to_string()))?;
517 EventBuilder::new(Kind::Custom(kind::INVITE_LIST), content)
518 .finalize(my_keys)
519 .map_err(|e| InviteError::Crypto(e.to_string()))
520}
521
522pub fn parse_invite_list_event(event: &Event, my_keys: &Keys) -> Result<InviteList, InviteError> {
526 use nostr_sdk::prelude::nip44::decrypt;
527 if event.kind.as_u16() != kind::INVITE_LIST {
528 return Err(InviteError::BadEvent("not a kind-13303 invite list"));
529 }
530 let json = decrypt(my_keys.secret_key(), &my_keys.public_key(), &event.content).map_err(|e| InviteError::Crypto(e.to_string()))?;
531 serde_json::from_str(&json).map_err(|e| InviteError::Json(e.to_string()))
532}
533
534pub fn build_registry_content(signers: &[PublicKey]) -> String {
542 let hexes: Vec<String> = signers.iter().map(|p| p.to_hex()).collect();
543 serde_json::to_string(&hexes).expect("Vec<String> always serializes")
544}
545
546pub fn parse_registry_content(content: &str) -> Result<Vec<PublicKey>, InviteError> {
548 let hexes: Vec<String> = serde_json::from_str(content).map_err(|e| InviteError::Json(e.to_string()))?;
549 hexes
550 .iter()
551 .map(|h| PublicKey::from_hex(h).map_err(|_| InviteError::BadHex("registry signer")))
552 .collect()
553}
554
555pub fn build_direct_invite(
566 inviter_keys: &Keys,
567 recipient: &PublicKey,
568 bundle: &CommunityInvite,
569) -> Result<Event, InviteError> {
570 let json = serde_json::to_string(bundle).map_err(|e| InviteError::Json(e.to_string()))?;
571
572 let mut rumor = EventBuilder::new(Kind::Custom(kind::DIRECT_INVITE), json)
574 .custom_created_at(Timestamp::now())
575 .finalize_unsigned_with_id(inviter_keys.public_key());
576 rumor.ensure_id();
577
578 let seal_content = nip44::encrypt(inviter_keys.secret_key(), recipient, rumor.as_json(), nip44::Version::default())
579 .map_err(|e| InviteError::Crypto(e.to_string()))?;
580 let seal = EventBuilder::new(Kind::Seal, seal_content)
581 .custom_created_at(Timestamp::tweaked(crate::sending::NIP59_RANDOM_TIMESTAMP_TWEAK))
582 .finalize(inviter_keys)
583 .map_err(|e| InviteError::Crypto(e.to_string()))?;
584
585 let ephemeral = Keys::generate();
586 let wrap_content = nip44::encrypt(ephemeral.secret_key(), recipient, seal.as_json(), nip44::Version::default())
587 .map_err(|e| InviteError::Crypto(e.to_string()))?;
588 let mut tags = vec![
589 Tag::public_key(*recipient),
590 Tag::custom("k", [kind::DIRECT_INVITE.to_string()]),
591 ];
592 if let Some(ms) = bundle.expires_at {
593 tags.push(Tag::custom("expiration", [(ms / 1000).to_string()]));
594 }
595 EventBuilder::new(Kind::GiftWrap, wrap_content)
596 .tags(tags)
597 .custom_created_at(Timestamp::tweaked(crate::sending::NIP59_RANDOM_TIMESTAMP_TWEAK))
598 .finalize(&ephemeral)
599 .map_err(|e| InviteError::Crypto(e.to_string()))
600}
601
602pub fn unwrap_direct_invite(wrap: &Event, recipient_keys: &Keys) -> Result<(PublicKey, CommunityInvite), InviteError> {
609 if wrap.kind != Kind::GiftWrap {
610 return Err(InviteError::BadEvent("not a gift wrap"));
611 }
612 let seal_json = nip44::decrypt(recipient_keys.secret_key(), &wrap.pubkey, &wrap.content)
613 .map_err(|e| InviteError::Crypto(e.to_string()))?;
614 let seal = Event::from_json(&seal_json).map_err(|e| InviteError::Json(e.to_string()))?;
615 if seal.kind != Kind::Seal {
616 return Err(InviteError::BadEvent("inner is not a seal"));
617 }
618 seal.verify().map_err(|_| InviteError::BadEvent("seal signature invalid"))?;
619
620 let rumor_json = nip44::decrypt(recipient_keys.secret_key(), &seal.pubkey, &seal.content)
621 .map_err(|e| InviteError::Crypto(e.to_string()))?;
622 let rumor = UnsignedEvent::from_json(rumor_json.as_bytes()).map_err(|e| InviteError::Json(e.to_string()))?;
623 if rumor.kind.as_u16() != kind::DIRECT_INVITE {
624 return Err(InviteError::BadEvent("rumor is not a direct invite"));
625 }
626 if rumor.pubkey != seal.pubkey {
627 return Err(InviteError::BadEvent("rumor author does not match the seal signer"));
628 }
629 let bundle = CommunityInvite::from_bundle_json(&rumor.content)?;
630 Ok((seal.pubkey, bundle))
631}
632
633pub async fn build_invite_list_event_signed<S: crate::signer::VectorSigner + ?Sized>(
638 signer: &S,
639 my_pk: PublicKey,
640 list: &InviteList,
641) -> Result<Event, InviteError> {
642 let json = serde_json::to_string(list).map_err(|e| InviteError::Json(e.to_string()))?;
643 let content = signer.nip44_encrypt_async(&my_pk, &json).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
644 let unsigned = EventBuilder::new(Kind::Custom(kind::INVITE_LIST), content).finalize_unsigned_with_id(my_pk);
645 signer.sign_event_async(unsigned).await.map_err(|e| InviteError::Crypto(e.to_string()))
646}
647
648pub async fn parse_invite_list_event_signed<S: crate::signer::VectorSigner + ?Sized>(
650 signer: &S,
651 my_pk: PublicKey,
652 event: &Event,
653) -> Result<InviteList, InviteError> {
654 if event.kind.as_u16() != kind::INVITE_LIST {
655 return Err(InviteError::BadEvent("not a kind-13303 invite list"));
656 }
657 let json = signer.nip44_decrypt_async(&my_pk, &event.content).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
658 serde_json::from_str(&json).map_err(|e| InviteError::Json(e.to_string()))
659}
660
661pub async fn build_direct_invite_signed<S: crate::signer::VectorSigner + ?Sized>(
664 signer: &S,
665 inviter_pk: PublicKey,
666 recipient: &PublicKey,
667 bundle: &CommunityInvite,
668) -> Result<Event, InviteError> {
669 let json = serde_json::to_string(bundle).map_err(|e| InviteError::Json(e.to_string()))?;
670 let mut rumor = EventBuilder::new(Kind::Custom(kind::DIRECT_INVITE), json)
671 .custom_created_at(Timestamp::now())
672 .finalize_unsigned_with_id(inviter_pk);
673 rumor.ensure_id();
674 let rumor_json = rumor.as_json();
675 let seal_content = signer.nip44_encrypt_async(recipient, &rumor_json).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
676 let seal_unsigned = EventBuilder::new(Kind::Seal, seal_content)
677 .custom_created_at(Timestamp::tweaked(crate::sending::NIP59_RANDOM_TIMESTAMP_TWEAK))
678 .finalize_unsigned_with_id(inviter_pk);
679 let seal = signer.sign_event_async(seal_unsigned).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
680 let ephemeral = Keys::generate();
681 let wrap_content = nip44::encrypt(ephemeral.secret_key(), recipient, seal.as_json(), nip44::Version::default())
682 .map_err(|e| InviteError::Crypto(e.to_string()))?;
683 let mut tags = vec![
684 Tag::public_key(*recipient),
685 Tag::custom("k", [kind::DIRECT_INVITE.to_string()]),
686 ];
687 if let Some(ms) = bundle.expires_at {
688 tags.push(Tag::custom("expiration", [(ms / 1000).to_string()]));
689 }
690 EventBuilder::new(Kind::GiftWrap, wrap_content)
691 .tags(tags)
692 .custom_created_at(Timestamp::tweaked(crate::sending::NIP59_RANDOM_TIMESTAMP_TWEAK))
693 .finalize(&ephemeral)
694 .map_err(|e| InviteError::Crypto(e.to_string()))
695}
696
697pub async fn unwrap_direct_invite_signed<S: crate::signer::VectorSigner + ?Sized>(
700 signer: &S,
701 wrap: &Event,
702) -> Result<(PublicKey, CommunityInvite), InviteError> {
703 if wrap.kind != Kind::GiftWrap {
704 return Err(InviteError::BadEvent("not a gift wrap"));
705 }
706 let seal_json = signer.nip44_decrypt_async(&wrap.pubkey, &wrap.content).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
707 let seal = Event::from_json(&seal_json).map_err(|e| InviteError::Json(e.to_string()))?;
708 if seal.kind != Kind::Seal {
709 return Err(InviteError::BadEvent("inner is not a seal"));
710 }
711 seal.verify().map_err(|_| InviteError::BadEvent("seal signature invalid"))?;
712 let rumor_json = signer.nip44_decrypt_async(&seal.pubkey, &seal.content).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
713 let rumor = UnsignedEvent::from_json(rumor_json.as_bytes()).map_err(|e| InviteError::Json(e.to_string()))?;
714 if rumor.kind.as_u16() != kind::DIRECT_INVITE {
715 return Err(InviteError::BadEvent("rumor is not a direct invite"));
716 }
717 if rumor.pubkey != seal.pubkey {
718 return Err(InviteError::BadEvent("rumor author does not match the seal signer"));
719 }
720 let bundle = CommunityInvite::from_bundle_json(&rumor.content)?;
721 Ok((seal.pubkey, bundle))
722}
723
724fn hex32(s: &str, field: &'static str) -> Result<[u8; 32], InviteError> {
727 crate::simd::hex::hex_to_bytes_32_checked(s).ok_or(InviteError::BadHex(field))
728}
729
730fn dict_id(url: &str) -> Option<u8> {
731 RELAY_DICT.iter().position(|u| *u == url).map(|i| (i + 1) as u8)
732}
733
734fn dict_url(id: u8) -> Option<&'static str> {
735 RELAY_DICT.get((id as usize).checked_sub(1)?).copied()
736}
737
738fn d_empty() -> Tag {
739 Tag::custom("d", [""])
740}
741
742fn vsk_tag(value: &str) -> Tag {
743 Tag::custom("vsk", [value])
744}
745
746fn first_tag(event: &Event, name: &str) -> Option<String> {
747 event.tags.iter().find_map(|t| {
748 let s = t.as_slice();
749 (s.len() >= 2 && s[0] == name).then(|| s[1].clone())
750 })
751}
752
753pub(crate) fn seal_bundle(bundle_key: &[u8; 32], json: &str) -> Result<String, InviteError> {
757 let ck = ConversationKey::new(*bundle_key);
758 let ct = crate::community::cipher::encrypt_with_random_nonce(&ck, json.as_bytes()).map_err(|e| InviteError::Crypto(e.to_string()))?;
759 Ok(base64_simd::STANDARD.encode_to_string(&ct))
760}
761
762fn open_bundle(bundle_key: &[u8; 32], content: &str) -> Result<String, InviteError> {
763 let ck = ConversationKey::new(*bundle_key);
764 let ct = base64_simd::STANDARD
765 .decode_to_vec(content.as_bytes())
766 .map_err(|e| InviteError::Crypto(e.to_string()))?;
767 let pt = decrypt_to_bytes(&ck, &ct).map_err(|e| InviteError::Crypto(e.to_string()))?;
768 String::from_utf8(pt).map_err(|e| InviteError::Crypto(e.to_string()))
769}
770
771fn signer_from_naddr(naddr: &str) -> Result<PublicKey, InviteError> {
772 let trimmed = naddr.trim().trim_start_matches("nostr:");
773 let n19 = nostr_sdk::prelude::nip19::Nip19::from_bech32(trimmed).map_err(|_| InviteError::BadLink("invalid naddr"))?;
774 match n19 {
775 nostr_sdk::prelude::nip19::Nip19::Coordinate(c)
776 if c.coordinate.kind.as_u16() == kind::INVITE_BUNDLE && c.coordinate.identifier.is_empty() =>
777 {
778 Ok(c.coordinate.public_key)
779 }
780 _ => Err(InviteError::BadLink("naddr is not an invite-bundle coordinate")),
781 }
782}
783
784#[cfg(test)]
785mod tests {
786 use super::super::control::CommunityIdentity;
787 use super::super::derive::invite_bundle_key;
788 use super::*;
789 use crate::community::{random_32, MAX_COMMUNITY_RELAYS};
790
791 fn hex(bytes: &[u8; 32]) -> String {
792 crate::simd::hex::bytes_to_hex_32(bytes)
793 }
794
795 fn valid_bundle() -> (Keys, CommunityInvite) {
797 let owner = Keys::generate();
798 let id = CommunityIdentity::mint(&owner.public_key());
799 let bundle = CommunityInvite {
800 community_id: hex(&id.community_id.0),
801 owner: hex(&id.owner_xonly),
802 owner_salt: hex(&id.owner_salt),
803 community_root: hex(&random_32()),
804 root_epoch: 0,
805 channels: vec![],
806 relays: vec!["wss://a.example".into(), "wss://b.example".into()],
807 name: "Test community".into(),
808 icon: None,
809 expires_at: None,
810 creator_npub: None,
811 label: None,
812 extra: Default::default(),
813 };
814 (owner, bundle)
815 }
816
817 fn token16() -> [u8; TOKEN_LEN] {
818 std::array::from_fn(|i| i as u8) }
820
821 #[test]
824 fn validate_accepts_a_correct_owner_salt_id_triple() {
825 let (_owner, bundle) = valid_bundle();
826 assert!(bundle.validate().is_ok());
827 }
828
829 #[test]
830 fn validate_rejects_a_forged_owner() {
831 let (_owner, mut bundle) = valid_bundle();
832 let attacker = Keys::generate();
834 bundle.owner = hex(&attacker.public_key().to_bytes());
835 assert!(matches!(bundle.validate(), Err(InviteError::OwnerMismatch)));
836 }
837
838 #[test]
839 fn from_json_rejects_over_the_channel_cap() {
840 let (_owner, mut bundle) = valid_bundle();
841 bundle.channels = (0..MAX_BUNDLE_CHANNELS + 1)
842 .map(|_| ChannelGrant { id: hex(&random_32()), key: hex(&random_32()), epoch: 0, name: "x".into() })
843 .collect();
844 let json = serde_json::to_string(&bundle).unwrap();
845 assert!(matches!(
846 CommunityInvite::from_bundle_json(&json),
847 Err(InviteError::TooManyChannels(n)) if n == MAX_BUNDLE_CHANNELS + 1
848 ));
849 }
850
851 #[test]
852 fn from_json_truncates_relays_to_the_community_cap() {
853 let (_owner, mut bundle) = valid_bundle();
854 bundle.relays = (0..MAX_COMMUNITY_RELAYS + 3).map(|i| format!("wss://r{i}.example")).collect();
855 let json = serde_json::to_string(&bundle).unwrap();
856 let parsed = CommunityInvite::from_bundle_json(&json).unwrap();
857 assert_eq!(parsed.relays.len(), MAX_COMMUNITY_RELAYS);
858 }
859
860 #[test]
861 fn unknown_bundle_fields_round_trip() {
862 let (_owner, bundle) = valid_bundle();
865 let mut value = serde_json::to_value(&bundle).unwrap();
866 let obj = value.as_object_mut().unwrap();
867 obj.insert("held_roots".into(), serde_json::json!([{ "epoch": 2, "key": "ab" }]));
868 obj.insert("refounder".into(), serde_json::json!("deadbeef"));
869 obj.insert("future_field".into(), serde_json::json!({ "deep": [1, 2] }));
870 let json = serde_json::to_string(&value).unwrap();
871
872 let parsed = CommunityInvite::from_bundle_json(&json).unwrap();
873 let out: serde_json::Value = serde_json::from_str(&serde_json::to_string(&parsed).unwrap()).unwrap();
874 assert_eq!(out["held_roots"][0]["epoch"], 2);
875 assert_eq!(out["refounder"], "deadbeef");
876 assert_eq!(out["future_field"]["deep"][1], 2);
877 }
878
879 #[test]
880 fn expiry_is_a_join_gate_not_a_render_gate() {
881 let (_owner, mut bundle) = valid_bundle();
882 bundle.expires_at = Some(1_000_000);
883 assert!(!bundle.expired(999_999));
884 assert!(bundle.expired(1_000_001));
885 }
886
887 #[test]
890 fn fragment_golden_stock_set() {
891 let token = token16();
895 let frag = encode_fragment(&token, &stock_relays()).unwrap();
896 assert_eq!(frag, "BAEAAQIDBAUGBwgJCgsMDQ4P");
897 let (t, relays) = decode_fragment(&frag).unwrap();
898 assert_eq!(t, token);
899 assert_eq!(relays, stock_relays());
900 }
901
902 #[test]
903 fn fragment_golden_dictionary_id_mix() {
904 let token = token16();
908 let relays = vec![RELAY_DICT[1].to_string(), RELAY_DICT[3].to_string()];
909 let frag = encode_fragment(&token, &relays).unwrap();
910 assert_eq!(frag, "BAACAgQAAQIDBAUGBwgJCgsMDQ4P");
911 let (t, out) = decode_fragment(&frag).unwrap();
912 assert_eq!(t, token);
913 assert_eq!(out, relays);
914 }
915
916 #[test]
917 fn fragment_golden_wss_implied_literal() {
918 let token = token16();
922 let relays = vec!["wss://x.y".to_string()];
923 let frag = encode_fragment(&token, &relays).unwrap();
924 assert_eq!(frag, "BAABAAN4LnkAAQIDBAUGBwgJCgsMDQ4P");
925 let (t, out) = decode_fragment(&frag).unwrap();
926 assert_eq!(t, token);
927 assert_eq!(out, relays);
928 }
929
930 #[test]
931 fn fragment_golden_verbatim_literal() {
932 let token = token16();
936 let relays = vec!["ws://h".to_string()];
937 let frag = encode_fragment(&token, &relays).unwrap();
938 assert_eq!(frag, "BAAB_wZ3czovL2gAAQIDBAUGBwgJCgsMDQ4P");
939 let (t, out) = decode_fragment(&frag).unwrap();
940 assert_eq!(t, token);
941 assert_eq!(out, relays);
942 }
943
944 #[test]
945 fn fragment_rejects_wrong_version_both_directions() {
946 let token = token16();
947 for bad in [3u8, 5u8] {
948 let mut bytes = vec![bad, FLAG_STOCK_SET];
949 bytes.extend_from_slice(&token);
950 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
951 assert!(matches!(decode_fragment(&frag), Err(InviteError::BadVersion(v)) if v == bad));
952 }
953 }
954
955 #[test]
956 fn fragment_rejects_trailing_bytes() {
957 let token = token16();
958 let mut bytes = vec![FRAGMENT_VERSION, FLAG_STOCK_SET];
959 bytes.extend_from_slice(&token);
960 bytes.push(0xff); let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
962 assert!(matches!(decode_fragment(&frag), Err(InviteError::BadFragment("trailing bytes"))));
963 }
964
965 #[test]
966 fn fragment_rejects_count_over_three() {
967 let token = token16();
968 let mut bytes = vec![FRAGMENT_VERSION, 0x00, 0x04]; bytes.extend_from_slice(&[1, 2, 3, 4]);
970 bytes.extend_from_slice(&token);
971 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
972 assert!(matches!(decode_fragment(&frag), Err(InviteError::BadFragment("too many bootstrap relays"))));
973 }
974
975 #[test]
976 fn fragment_skips_an_unknown_dictionary_id() {
977 let token = token16();
978 let mut bytes = vec![FRAGMENT_VERSION, 0x00, 0x01, 200];
980 bytes.extend_from_slice(&token);
981 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
982 let (t, relays) = decode_fragment(&frag).unwrap();
983 assert_eq!(t, token);
984 assert!(relays.is_empty());
985 }
986
987 #[test]
988 fn fragment_caps_bootstrap_relays_at_three() {
989 let token = token16();
990 let relays: Vec<String> = (0..4).map(|i| format!("wss://r{i}.example")).collect();
991 let (_t, out) = decode_fragment(&encode_fragment(&token, &relays).unwrap()).unwrap();
992 assert_eq!(out.len(), MAX_BOOTSTRAP_RELAYS);
993 }
994
995 #[test]
998 fn bundle_event_round_trips_live() {
999 let (_owner, bundle) = valid_bundle();
1000 let link = Keys::generate();
1001 let token = random_32();
1002 let key = invite_bundle_key(&token[..TOKEN_LEN].try_into().unwrap());
1003 let event = build_bundle_event(&link, &bundle, &key).unwrap();
1004 assert_eq!(event.pubkey, link.public_key());
1005
1006 match parse_bundle_event(&event, &link.public_key(), &key).unwrap() {
1007 BundleState::Live(b) => {
1008 assert_eq!(b.community_id, bundle.community_id);
1009 assert_eq!(b.name, "Test community");
1010 }
1011 BundleState::Revoked => panic!("expected Live"),
1012 }
1013 }
1014
1015 #[test]
1016 fn revocation_reads_as_revoked() {
1017 let link = Keys::generate();
1018 let tomb = build_revocation(&link).unwrap();
1019 let key = invite_bundle_key(&[0u8; TOKEN_LEN]);
1020 assert!(matches!(parse_bundle_event(&tomb, &link.public_key(), &key), Ok(BundleState::Revoked)));
1021 }
1022
1023 #[test]
1024 fn bundle_event_wrong_key_fails() {
1025 let (_owner, bundle) = valid_bundle();
1026 let link = Keys::generate();
1027 let key = invite_bundle_key(&[7u8; TOKEN_LEN]);
1028 let event = build_bundle_event(&link, &bundle, &key).unwrap();
1029 let wrong = invite_bundle_key(&[8u8; TOKEN_LEN]);
1030 assert!(parse_bundle_event(&event, &link.public_key(), &wrong).is_err());
1031 }
1032
1033 #[test]
1034 fn bundle_event_from_a_different_author_is_rejected() {
1035 let (_owner, bundle) = valid_bundle();
1038 let real = Keys::generate();
1039 let squatter = Keys::generate();
1040 let key = invite_bundle_key(&[3u8; TOKEN_LEN]);
1041 let event = build_bundle_event(&squatter, &bundle, &key).unwrap();
1042 assert!(matches!(
1043 parse_bundle_event(&event, &real.public_key(), &key),
1044 Err(InviteError::BadEvent("author is not the link signer"))
1045 ));
1046 }
1047
1048 #[test]
1052 fn bundle_event_at_a_non_empty_d_is_rejected() {
1053 let (_owner, bundle) = valid_bundle();
1054 let link = Keys::generate();
1055 let key = invite_bundle_key(&[9u8; TOKEN_LEN]);
1056 let json = serde_json::to_string(&bundle).unwrap();
1057 let content = seal_bundle(&key, &json).unwrap();
1058 let event = EventBuilder::new(Kind::Custom(kind::INVITE_BUNDLE), content)
1059 .tags([Tag::identifier("elsewhere"), vsk_tag(vsk::INVITE_LIVE)])
1060 .finalize(&link)
1061 .unwrap();
1062 assert!(matches!(
1063 parse_bundle_event(&event, &link.public_key(), &key),
1064 Err(InviteError::BadEvent("bundle is not at the link's coordinate"))
1065 ));
1066 }
1067
1068 #[test]
1069 fn bundle_event_with_a_tampered_signature_is_rejected() {
1070 let (_owner, bundle) = valid_bundle();
1071 let link = Keys::generate();
1072 let other = Keys::generate();
1073 let key = invite_bundle_key(&[4u8; TOKEN_LEN]);
1074 let event = build_bundle_event(&link, &bundle, &key).unwrap();
1075 let mut json: serde_json::Value = serde_json::from_str(&event.as_json()).unwrap();
1077 json["pubkey"] = serde_json::Value::String(other.public_key().to_hex());
1078 let Ok(forged) = Event::from_json(json.to_string()) else { return };
1079 assert!(matches!(
1080 parse_bundle_event(&forged, &other.public_key(), &key),
1081 Err(InviteError::BadEvent("signature invalid"))
1082 ));
1083 }
1084
1085 fn entry(token: &str, community: &str) -> InviteEntry {
1088 InviteEntry {
1089 token: token.into(),
1090 signer_sk: "bb".repeat(32),
1091 community_id: community.into(),
1092 url: "https://x/invite/naddr1xyz#frag".into(),
1093 label: None,
1094 created_at: 1000,
1095 expires_at: None,
1096 extra: Default::default(),
1097 }
1098 }
1099
1100 #[test]
1101 fn invite_list_merge_entries_immutable_tombstone_wins_terminally() {
1102 let e = entry(&"aa".repeat(16), &"cc".repeat(32));
1103 let a = merge_invite_lists(InviteList::default(), InviteList { entries: vec![e.clone()], ..Default::default() });
1104 assert_eq!(a.entries.len(), 1);
1105
1106 let mut mutated = e.clone();
1108 mutated.label = Some("changed".into());
1109 let still = merge_invite_lists(a.clone(), InviteList { entries: vec![mutated], ..Default::default() });
1110 assert_eq!(still.entries[0].label, None);
1111
1112 let tomb = InviteTombstone { token: e.token.clone(), community_id: e.community_id.clone(), extra: Default::default() };
1114 let b = merge_invite_lists(a, InviteList { tombstones: vec![tomb], ..Default::default() });
1115 assert!(b.entries.is_empty());
1116 assert_eq!(b.tombstones.len(), 1);
1117
1118 let c = merge_invite_lists(b, InviteList { entries: vec![e], ..Default::default() });
1120 assert!(c.entries.is_empty());
1121 }
1122
1123 #[test]
1124 fn invite_list_tombstones_union() {
1125 let t1 = InviteTombstone { token: "11".repeat(16), community_id: "aa".repeat(32), extra: Default::default() };
1126 let t2 = InviteTombstone { token: "22".repeat(16), community_id: "bb".repeat(32), extra: Default::default() };
1127 let merged = merge_invite_lists(
1128 InviteList { tombstones: vec![t1], ..Default::default() },
1129 InviteList { tombstones: vec![t2], ..Default::default() },
1130 );
1131 assert_eq!(merged.tombstones.len(), 2);
1132 }
1133
1134 #[test]
1135 fn invite_list_unknown_fields_round_trip() {
1136 let wire = r#"{"entries":[],"tombstones":[],"schema":"future","note":{"x":1}}"#;
1137 let list: InviteList = serde_json::from_str(wire).unwrap();
1138 let out: serde_json::Value = serde_json::from_str(&serde_json::to_string(&list).unwrap()).unwrap();
1139 assert_eq!(out["schema"], "future");
1140 assert_eq!(out["note"]["x"], 1);
1141 }
1142
1143 #[test]
1146 fn direct_invite_round_trips_to_inviter_and_bundle() {
1147 let inviter = Keys::generate();
1148 let recipient = Keys::generate();
1149 let (_owner, bundle) = valid_bundle();
1150
1151 let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1152 assert_eq!(wrap.kind, Kind::GiftWrap);
1153 assert_ne!(wrap.pubkey, inviter.public_key(), "wrap author is ephemeral");
1154 assert!(wrap.tags.iter().any(|t| {
1155 let s = t.as_slice();
1156 s.len() >= 2 && s[0] == "k" && s[1] == kind::DIRECT_INVITE.to_string()
1157 }));
1158
1159 let (sender, out) = unwrap_direct_invite(&wrap, &recipient).unwrap();
1160 assert_eq!(sender, inviter.public_key());
1161 assert_eq!(out.community_id, bundle.community_id);
1162 assert_eq!(out.name, "Test community");
1163 }
1164
1165 #[test]
1166 fn direct_invite_rejects_a_seal_claiming_a_false_inviter() {
1167 let inviter = Keys::generate();
1170 let claimed = Keys::generate();
1171 let recipient = Keys::generate();
1172 let (_owner, bundle) = valid_bundle();
1173
1174 let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1176 let seal_json = nip44::decrypt(recipient.secret_key(), &wrap.pubkey, &wrap.content).unwrap();
1177 let mut seal_val: serde_json::Value = serde_json::from_str(&seal_json).unwrap();
1178 seal_val["pubkey"] = serde_json::Value::String(claimed.public_key().to_hex());
1179 let ephemeral = Keys::generate();
1181 let wrap_ct =
1182 nip44::encrypt(ephemeral.secret_key(), &recipient.public_key(), seal_val.to_string(), nip44::Version::default())
1183 .unwrap();
1184 let forged = EventBuilder::new(Kind::GiftWrap, wrap_ct)
1185 .tags([Tag::public_key(recipient.public_key())])
1186 .finalize(&ephemeral)
1187 .unwrap();
1188 assert!(unwrap_direct_invite(&forged, &recipient).is_err());
1189 }
1190
1191 #[test]
1192 fn direct_invite_rejects_a_non_invite_rumor_kind() {
1193 let inviter = Keys::generate();
1194 let recipient = Keys::generate();
1195 let (_owner, bundle) = valid_bundle();
1196 let json = serde_json::to_string(&bundle).unwrap();
1197
1198 let mut rumor = EventBuilder::new(Kind::Custom(9), json)
1200 .custom_created_at(Timestamp::now())
1201 .finalize_unsigned_with_id(inviter.public_key());
1202 rumor.ensure_id();
1203 let seal_ct =
1204 nip44::encrypt(inviter.secret_key(), &recipient.public_key(), rumor.as_json(), nip44::Version::default()).unwrap();
1205 let seal = EventBuilder::new(Kind::Seal, seal_ct).finalize(&inviter).unwrap();
1206 let ephemeral = Keys::generate();
1207 let wrap_ct =
1208 nip44::encrypt(ephemeral.secret_key(), &recipient.public_key(), seal.as_json(), nip44::Version::default()).unwrap();
1209 let wrap = EventBuilder::new(Kind::GiftWrap, wrap_ct)
1210 .tags([Tag::public_key(recipient.public_key())])
1211 .finalize(&ephemeral)
1212 .unwrap();
1213 assert!(matches!(
1214 unwrap_direct_invite(&wrap, &recipient),
1215 Err(InviteError::BadEvent("rumor is not a direct invite"))
1216 ));
1217 }
1218
1219 #[test]
1220 fn direct_invite_stamps_nip40_expiration_in_seconds() {
1221 let inviter = Keys::generate();
1222 let recipient = Keys::generate();
1223 let (_owner, mut bundle) = valid_bundle();
1224 let expires_ms = 1_735_689_600_000u64;
1225 bundle.expires_at = Some(expires_ms);
1226
1227 let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1228 let exp = wrap.tags.iter().find_map(|t| {
1229 let s = t.as_slice();
1230 (s.len() >= 2 && s[0] == "expiration").then(|| s[1].clone())
1231 });
1232 assert_eq!(exp, Some((expires_ms / 1000).to_string()));
1233 }
1234
1235 #[test]
1236 fn direct_invite_rejects_a_forged_owner_bundle() {
1237 let inviter = Keys::generate();
1238 let recipient = Keys::generate();
1239 let (_owner, mut bundle) = valid_bundle();
1240 bundle.owner = hex(&Keys::generate().public_key().to_bytes()); let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1242 assert!(matches!(unwrap_direct_invite(&wrap, &recipient), Err(InviteError::OwnerMismatch)));
1243 }
1244
1245 #[test]
1248 fn invite_link_round_trips_full_url_and_bare_form() {
1249 let link = Keys::generate();
1250 let token = token16();
1251 let relays = vec!["wss://a.example".to_string()];
1252 let url = build_invite_url("https://vectorapp.io", &link.public_key(), &token, &relays).unwrap();
1253 assert!(url.contains("/invite/naddr1"));
1254
1255 let parsed = parse_invite_link(&url).unwrap();
1256 assert_eq!(parsed.link_signer, link.public_key());
1257 assert_eq!(parsed.token, token);
1258 assert_eq!(parsed.bootstrap_relays, relays);
1259
1260 let bare = format!("{}#{}", parsed.naddr, url.split('#').nth(1).unwrap());
1261 assert_eq!(parse_invite_link(&bare).unwrap().link_signer, link.public_key());
1262 }
1263
1264 #[test]
1265 fn invite_link_rejects_non_invites() {
1266 assert!(parse_invite_link("hello world").is_err());
1267 assert!(parse_invite_link("wss://relay.example.com").is_err());
1268 assert!(parse_invite_link("https://x/invite/#frag").is_err());
1269 }
1270
1271 #[test]
1272 fn v2_parser_rejects_a_v1_style_url_no_cross_protocol_confusion() {
1273 let v1_url = "https://vectorapp.io/invite#AmR1bW15djFmcmFnbWVudA";
1279 assert!(parse_invite_link(v1_url).is_err(), "v2 must reject a v1-format invite URL");
1280 assert!(parse_invite_link("https://vectorapp.io/invite/#AmR1bW15").is_err());
1282 let token = [0x07u8; TOKEN_LEN];
1284 let signer = Keys::generate();
1285 let naddr = bundle_naddr(&signer.public_key()).unwrap();
1286 let frag = encode_fragment(&token, &[]).unwrap();
1287 assert!(parse_invite_link(&format!("{naddr}#{frag}")).is_ok());
1288 }
1289
1290 #[test]
1293 fn registry_content_round_trips_and_leaks_no_secrets() {
1294 let a = Keys::generate();
1295 let b = Keys::generate();
1296 let content = build_registry_content(&[a.public_key(), b.public_key()]);
1297
1298 let arr: Vec<String> = serde_json::from_str(&content).unwrap();
1300 assert_eq!(arr, vec![a.public_key().to_hex(), b.public_key().to_hex()]);
1301 assert!(!content.contains("invite"));
1303 assert!(!content.contains("token"));
1304 assert!(!content.contains("://"));
1305
1306 let back = parse_registry_content(&content).unwrap();
1307 assert_eq!(back, vec![a.public_key(), b.public_key()]);
1308 }
1309}