Skip to main content

vector_core/community/
public_invite.rs

1//! Public (link) invites (GROUP_PROTOCOL.md).
2//!
3//! A public invite is a shareable URL — `https://vectorapp.io/invite#<fragment>` — whose
4//! `#fragment` carries a **fetch-token**, never the keys directly. The token decrypts a
5//! bundle posted on the community's relays. This indirection (token → relay bundle) is
6//! what buys Discord-style rotate/revoke/expire without changing the URL.
7//!
8//! From the token, three sub-keys derive ([`super::derive`]):
9//! - a NIP-44 **decryption key** for the bundle content;
10//! - a **locator** (the addressable `d`-tag) so the bundle is findable on relays;
11//! - a stable **signer key**, so re-posting under one coordinate rotates the link and a
12//!   joiner can reject an impostor bundle squatting the locator.
13//!
14//! The bundle carries the same join material a private invite does ([`CommunityInvite`])
15//! plus a **preview** (name, description, logo) so a recipient sees what they're joining
16//! before committing. The preview lives inside the token-gated ciphertext, so a relay
17//! scraper without the link sees only an opaque blob — metadata privacy holds.
18
19use nostr_sdk::prelude::*;
20use serde::{Deserialize, Serialize};
21
22use super::derive::{public_invite_key, public_invite_locator, public_invite_signer};
23use super::invite::{build_invite, CommunityInvite};
24use super::{cipher, Community, CommunityImage};
25use crate::stored_event::event_kind;
26
27/// Default invite URL base. The path is irrelevant to the protocol (the fragment is read
28/// client-side and never sent to the server); only the fragment matters.
29pub const INVITE_URL_BASE: &str = "https://vectorapp.io/invite";
30
31/// Cap on relays embedded in an invite URL. The URL is attacker-controlled (anyone can
32/// craft one and share it), and its relays feed straight into the connect loop on
33/// preview/accept — bound it so a hostile link can't fan out connections.
34const MAX_URL_RELAYS: usize = 32;
35
36const TAG_VERSION: &str = "v";
37const TAG_SUBKIND: &str = "vsk";
38const PROTOCOL_VERSION: &str = "1";
39/// Sub-kind for a public-invite bundle event (append-only enum; distinct author
40/// coordinate, but tagged for explicit parse-time disambiguation).
41const VSK_PUBLIC_INVITE: &str = "6";
42/// Sub-kind for a REVOCATION tombstone — the empty replaceable event that overwrites a revoked bundle at
43/// its coordinate (append-only). A self-describing marker so a fetcher (the invite preview page, a
44/// joining client) reads "this invite was revoked" instantly, instead of choking on an empty decrypt.
45const VSK_PUBLIC_INVITE_REVOKED: &str = "9";
46
47/// The non-secret community details shown before joining (no member count — the protocol
48/// hides membership, so any count would be a fiction).
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct PublicInvitePreview {
51    pub name: String,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub description: Option<String>,
54    /// Logo (encrypted blob ref). The per-image key rides here; the joiner also receives
55    /// the server-root key via `join`, so this exposes nothing they aren't already given.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub icon: Option<CommunityImage>,
58}
59
60/// The full decrypted bundle: a preview + the join material + an optional expiry + attribution.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct PublicInviteBundle {
63    pub preview: PublicInvitePreview,
64    pub join: CommunityInvite,
65    /// Unix seconds after which clients refuse the link (`None` = no expiry).
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub expires_at: Option<u64>,
68    /// Attribution (metrics): the link creator's npub (bech32) so a joiner can announce "invited by
69    /// X" in their join Presence. `serde(default)` so older bundles (no attribution) still parse.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub creator_npub: Option<String>,
72    /// Creator-set label for the link ("Reddit", "Conf 2026") — the metric's human-readable bucket.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub label: Option<String>,
75}
76
77impl PublicInviteBundle {
78    pub fn is_expired(&self, now_secs: u64) -> bool {
79        self.expires_at.map_or(false, |e| now_secs >= e)
80    }
81}
82
83/// Errors building or parsing a public invite.
84#[derive(Debug)]
85pub enum PublicInviteError {
86    Json(String),
87    Cipher(String),
88    Sign(String),
89    /// Bundle event not signed by the token-derived signer (impostor at the locator).
90    UnexpectedSigner,
91    BadSignature,
92    WrongVersion(Option<String>),
93    WrongSubKind(Option<String>),
94    BadUrl(String),
95    Expired,
96    /// The coordinate holds a token-signed revocation tombstone — the link was explicitly revoked.
97    Revoked,
98}
99
100impl std::fmt::Display for PublicInviteError {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        match self {
103            PublicInviteError::Json(e) => write!(f, "json: {e}"),
104            PublicInviteError::Cipher(e) => write!(f, "cipher: {e}"),
105            PublicInviteError::Sign(e) => write!(f, "sign: {e}"),
106            PublicInviteError::UnexpectedSigner => write!(f, "bundle not signed by the invite token"),
107            PublicInviteError::BadSignature => write!(f, "bundle signature invalid"),
108            PublicInviteError::WrongVersion(v) => write!(f, "unsupported invite version: {v:?}"),
109            PublicInviteError::WrongSubKind(v) => write!(f, "not a public-invite bundle: {v:?}"),
110            PublicInviteError::BadUrl(e) => write!(f, "bad invite url: {e}"),
111            PublicInviteError::Expired => write!(f, "invite has expired"),
112            PublicInviteError::Revoked => write!(f, "this invite was revoked"),
113        }
114    }
115}
116
117impl std::error::Error for PublicInviteError {}
118
119/// Mint a fresh 32-byte token (OsRng). The whole secret of a public invite.
120pub fn new_token() -> [u8; 32] {
121    super::random_32()
122}
123
124/// Snapshot a Community's current display metadata into a preview.
125fn preview_of(community: &Community) -> PublicInvitePreview {
126    PublicInvitePreview {
127        name: community.name.clone(),
128        description: community.description.clone(),
129        icon: community.icon.clone(),
130    }
131}
132
133/// Build the signed, token-encrypted bundle event to post on the community's relays.
134/// Addressable (kind 30078) at coordinate `(30078, signer(token), d=locator(token))` so
135/// re-posting under the same token replaces it (rotate); deleting it revokes the link.
136pub fn build_public_invite_event(
137    community: &Community,
138    token: &[u8; 32],
139    expires_at: Option<u64>,
140    creator_npub: Option<String>,
141    label: Option<String>,
142) -> Result<Event, PublicInviteError> {
143    let bundle = PublicInviteBundle {
144        preview: preview_of(community),
145        join: build_invite(community),
146        expires_at,
147        creator_npub,
148        label,
149    };
150    let json = serde_json::to_string(&bundle).map_err(|e| PublicInviteError::Json(e.to_string()))?;
151    let content = cipher::seal(&public_invite_key(token), json.as_bytes())
152        .map_err(PublicInviteError::Cipher)?;
153    let signer = Keys::new(public_invite_signer(token));
154    let locator = crate::simd::hex::bytes_to_hex_32(&public_invite_locator(token));
155    EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), content)
156        .tags([
157            Tag::identifier(locator),
158            Tag::custom(TagKind::Custom(TAG_SUBKIND.into()), [VSK_PUBLIC_INVITE.to_string()]),
159            Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]),
160        ])
161        .sign_with_keys(&signer)
162        .map_err(|e| PublicInviteError::Sign(e.to_string()))
163}
164
165/// Build a token-signed TOMBSTONE at the bundle's coordinate: an empty-content replaceable event with a
166/// fresh `created_at` that REPLACES the live bundle. Relays honor replaceable-event replacement far more
167/// reliably than NIP-09 `a`-tag (coordinate) deletions of addressable events — so publishing this on
168/// revoke, alongside the deletion, guarantees the bundle is overwritten (the browser preview dies) even on
169/// relays that silently ignore coordinate deletions. The empty content fails to decrypt to a valid bundle,
170/// so a fetcher gets nothing.
171pub fn build_public_invite_tombstone(token: &[u8; 32]) -> Result<Event, PublicInviteError> {
172    let signer = Keys::new(public_invite_signer(token));
173    let locator = crate::simd::hex::bytes_to_hex_32(&public_invite_locator(token));
174    EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "")
175        .tags([
176            Tag::identifier(locator),
177            Tag::custom(TagKind::Custom(TAG_SUBKIND.into()), [VSK_PUBLIC_INVITE_REVOKED.to_string()]),
178            Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]),
179        ])
180        .sign_with_keys(&signer)
181        .map_err(|e| PublicInviteError::Sign(e.to_string()))
182}
183
184/// The addressable `d`-tag (hex locator) a public invite for `token` is posted under —
185/// the value to query relays by.
186pub fn locator_hex(token: &[u8; 32]) -> String {
187    crate::simd::hex::bytes_to_hex_32(&public_invite_locator(token))
188}
189
190/// The public key a valid bundle for `token` must be signed by.
191pub fn signer_pubkey(token: &[u8; 32]) -> PublicKey {
192    Keys::new(public_invite_signer(token)).public_key()
193}
194
195/// Verify + decrypt a bundle event with the URL token. Checks: protocol version (before
196/// decrypt), sub-kind, that the author is the token-derived signer (rejects an impostor
197/// squatting the locator), the signature, then decrypts under the token key. Does NOT
198/// enforce expiry (so a preview can still render an expired link); callers gate joins on
199/// [`PublicInviteBundle::is_expired`].
200pub fn parse_public_invite_event(
201    event: &Event,
202    token: &[u8; 32],
203) -> Result<PublicInviteBundle, PublicInviteError> {
204    let version = find_tag(event, TAG_VERSION);
205    if version.as_deref() != Some(PROTOCOL_VERSION) {
206        return Err(PublicInviteError::WrongVersion(version));
207    }
208    // Author + signature FIRST, so only a genuine token-signed event at the coordinate gets a verdict —
209    // an impostor squatting the locator can spoof neither a bundle NOR a revocation.
210    if event.pubkey != signer_pubkey(token) {
211        return Err(PublicInviteError::UnexpectedSigner);
212    }
213    event.verify().map_err(|_| PublicInviteError::BadSignature)?;
214    let subkind = find_tag(event, TAG_SUBKIND);
215    match subkind.as_deref() {
216        Some(VSK_PUBLIC_INVITE) => {}
217        // A token-signed revocation tombstone (overwrites the bundle on revoke) → explicit "revoked".
218        Some(VSK_PUBLIC_INVITE_REVOKED) => return Err(PublicInviteError::Revoked),
219        other => return Err(PublicInviteError::WrongSubKind(other.map(|s| s.to_string()))),
220    }
221    let plaintext =
222        cipher::open(&public_invite_key(token), &event.content).map_err(PublicInviteError::Cipher)?;
223    serde_json::from_slice(&plaintext).map_err(|e| PublicInviteError::Json(e.to_string()))
224}
225
226fn find_tag(event: &Event, name: &str) -> Option<String> {
227    event.tags.iter().find_map(|t| {
228        let s = t.as_slice();
229        (s.len() >= 2 && s[0] == name).then(|| s[1].clone())
230    })
231}
232
233// --- URL encoding (the #fragment carries everything; the path/host are cosmetic) ---
234
235#[derive(Serialize, Deserialize)]
236struct UrlFragment {
237    v: u8,
238    /// Bootstrap relays — a fresh joiner has no community context to find the bundle.
239    relays: Vec<String>,
240    /// The fetch-token, hex.
241    t: String,
242}
243
244/// v2 fragment version byte. A v1 fragment is base64url(JSON) whose first decoded byte is
245/// `{` (0x7B), so the first payload byte discriminates the two formats for free.
246const URL_V2: u8 = 2;
247/// v2 flags bit: the community runs on the stock [`crate::state::TRUSTED_RELAYS`] set,
248/// encoded as ZERO bytes — the parser (and the website's copy) reconstitutes it.
249const V2_FLAG_DEFAULT_RELAYS: u8 = 0b0000_0001;
250/// Mint-side cap on explicit v2 bootstrap relays: the URL only has to FIND the bundle (the
251/// bundle carries the authoritative relay set), and 3 rides out one sick relay.
252const MAX_V2_BOOTSTRAP_RELAYS: usize = 3;
253
254/// Append-only dictionary of well-known relays for v2 links — a listed relay costs ONE byte
255/// instead of a literal string. Ids live in 1..=254 (0 = wss-implied literal, 255 = verbatim
256/// literal). NEVER renumber or remove entries (ids are baked into minted links forever);
257/// append only, in lockstep with the website's /invite parser copy.
258const RELAY_DICTIONARY: &[&str] = &[
259    "wss://jskitty.com/nostr",        // id 1
260    "wss://asia.vectorapp.io/nostr",  // id 2
261    "wss://nostr.computingcache.com", // id 3
262    "wss://relay.damus.io",           // id 4
263];
264
265/// Order/case/trailing-slash-insensitive relay comparison key.
266fn norm_relay(r: &str) -> String {
267    r.trim().trim_end_matches('/').to_ascii_lowercase()
268}
269
270fn is_default_relay_set(relays: &[String]) -> bool {
271    let defaults: std::collections::HashSet<String> =
272        crate::state::TRUSTED_RELAYS.iter().map(|r| norm_relay(r)).collect();
273    let theirs: std::collections::HashSet<String> = relays.iter().map(|r| norm_relay(r)).collect();
274    theirs == defaults
275}
276
277fn dictionary_id(relay: &str) -> Option<u8> {
278    let n = norm_relay(relay);
279    RELAY_DICTIONARY.iter().position(|d| norm_relay(d) == n).map(|i| (i + 1) as u8)
280}
281
282/// Build the shareable invite URL (v2 binary fragment): `[ver][flags][relays?][token:32]`,
283/// base64url. The stock relay set costs zero bytes (flag bit); known relays cost one byte
284/// each (dictionary); customs are length-prefixed literals with `wss://` implied. ~74 chars
285/// total in the common case, vs ~269 for the v1 JSON fragment it replaces.
286pub fn encode_invite_url(relays: &[String], token: &[u8; 32]) -> String {
287    let mut payload: Vec<u8> = Vec::with_capacity(34);
288    payload.push(URL_V2);
289    if is_default_relay_set(relays) {
290        payload.push(V2_FLAG_DEFAULT_RELAYS);
291    } else {
292        payload.push(0);
293        // Bootstrap only — the bundle carries the authoritative set. Relays over 255 bytes
294        // can't length-prefix; skip them (absurd in practice).
295        let boot: Vec<&String> = relays
296            .iter()
297            .filter(|r| r.strip_prefix("wss://").unwrap_or(r).len() <= 255)
298            .take(MAX_V2_BOOTSTRAP_RELAYS)
299            .collect();
300        payload.push(boot.len() as u8);
301        for r in boot {
302            match dictionary_id(r) {
303                Some(id) => payload.push(id),
304                None => {
305                    // Literal: wss:// (the overwhelmingly common scheme) rides implied as
306                    // entry 0; anything else is stored VERBATIM as entry 255 so the string
307                    // round-trips exactly (ws://, exotic schemes, test relays).
308                    let (kind, s) = match r.strip_prefix("wss://") {
309                        Some(host) => (0u8, host),
310                        None => (255u8, r.as_str()),
311                    };
312                    payload.push(kind);
313                    payload.push(s.len() as u8);
314                    payload.extend_from_slice(s.as_bytes());
315                }
316            }
317        }
318    }
319    payload.extend_from_slice(token);
320    let b64 = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&payload);
321    format!("{INVITE_URL_BASE}#{b64}")
322}
323
324/// Parse a shareable invite URL (or a bare fragment) back to `(relays, token)`. Accepts
325/// the full URL or just the fragment after `#`, in either the v2 binary format or the
326/// legacy v1 JSON format (v1 links in the wild stay valid forever).
327pub fn parse_invite_url(url: &str) -> Result<(Vec<String>, [u8; 32]), PublicInviteError> {
328    let fragment = url.rsplit_once('#').map(|(_, f)| f).unwrap_or(url);
329    if fragment.is_empty() {
330        return Err(PublicInviteError::BadUrl("no fragment".into()));
331    }
332    let raw = base64_simd::URL_SAFE_NO_PAD
333        .decode_to_vec(fragment.as_bytes())
334        .map_err(|e| PublicInviteError::BadUrl(format!("base64: {e}")))?;
335    match raw.first() {
336        Some(&URL_V2) => parse_v2_fragment(&raw),
337        Some(&b'{') => parse_v1_fragment(&raw),
338        _ => Err(PublicInviteError::BadUrl("unrecognized fragment format".into())),
339    }
340}
341
342fn parse_v1_fragment(json: &[u8]) -> Result<(Vec<String>, [u8; 32]), PublicInviteError> {
343    let frag: UrlFragment =
344        serde_json::from_slice(json).map_err(|e| PublicInviteError::BadUrl(format!("json: {e}")))?;
345    if frag.v != 1 {
346        return Err(PublicInviteError::BadUrl(format!("unsupported url version {}", frag.v)));
347    }
348    if frag.relays.len() > MAX_URL_RELAYS {
349        return Err(PublicInviteError::BadUrl(format!(
350            "invite url declares too many relays ({})",
351            frag.relays.len()
352        )));
353    }
354    // SIMD-validated decode (public invite-URL token): rejects non-hex / wrong length in-register.
355    let token = crate::simd::hex::hex_to_bytes_32_checked(&frag.t)
356        .ok_or_else(|| PublicInviteError::BadUrl("token must be 64 hex chars".into()))?;
357    Ok((frag.relays, token))
358}
359
360fn parse_v2_fragment(raw: &[u8]) -> Result<(Vec<String>, [u8; 32]), PublicInviteError> {
361    let bad = |m: &str| PublicInviteError::BadUrl(m.into());
362    let flags = *raw.get(1).ok_or_else(|| bad("truncated v2 fragment"))?;
363    let mut pos = 2usize;
364    let relays: Vec<String> = if flags & V2_FLAG_DEFAULT_RELAYS != 0 {
365        crate::state::TRUSTED_RELAYS.iter().map(|s| s.to_string()).collect()
366    } else {
367        // Parse cap is the lax v1 cap (not the mint cap) so a future build minting more
368        // bootstrap relays stays readable here.
369        let count = *raw.get(pos).ok_or_else(|| bad("truncated v2 relay count"))? as usize;
370        pos += 1;
371        if count == 0 || count > MAX_URL_RELAYS {
372            return Err(bad("bad v2 relay count"));
373        }
374        let mut out = Vec::with_capacity(count.min(MAX_V2_BOOTSTRAP_RELAYS));
375        for _ in 0..count {
376            let id = *raw.get(pos).ok_or_else(|| bad("truncated v2 relay entry"))?;
377            pos += 1;
378            if id == 0 || id == 255 {
379                let len = *raw.get(pos).ok_or_else(|| bad("truncated v2 relay literal"))? as usize;
380                pos += 1;
381                let end = pos.checked_add(len).ok_or_else(|| bad("bad v2 relay length"))?;
382                let host = raw.get(pos..end).ok_or_else(|| bad("truncated v2 relay literal"))?;
383                let host =
384                    std::str::from_utf8(host).map_err(|_| bad("v2 relay literal not utf-8"))?;
385                out.push(if id == 0 { format!("wss://{host}") } else { host.to_string() });
386                pos = end;
387            } else if let Some(relay) = RELAY_DICTIONARY.get(id as usize - 1) {
388                out.push(relay.to_string());
389            }
390            // Unknown dictionary id = an entry appended by a NEWER build — skip it
391            // (forward-compat); the remaining entries still bootstrap.
392        }
393        if out.is_empty() {
394            return Err(bad("no resolvable bootstrap relays"));
395        }
396        out
397    };
398    let token_bytes = raw.get(pos..).ok_or_else(|| bad("truncated v2 token"))?;
399    if token_bytes.len() != 32 {
400        return Err(bad("v2 token must be exactly 32 bytes"));
401    }
402    let mut token = [0u8; 32];
403    token.copy_from_slice(token_bytes);
404    Ok((relays, token))
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn v2_url_default_relay_set_roundtrips_and_is_tiny() {
413        let relays: Vec<String> =
414            crate::state::TRUSTED_RELAYS.iter().map(|s| s.to_string()).collect();
415        let token = new_token();
416        let url = encode_invite_url(&relays, &token);
417        assert!(url.len() <= 80, "default-set v2 url should be ~74 chars, got {}", url.len());
418        let (parsed_relays, parsed_token) = parse_invite_url(&url).unwrap();
419        assert_eq!(parsed_token, token);
420        assert_eq!(parsed_relays, relays);
421    }
422
423    #[test]
424    fn v2_url_dictionary_and_literal_relays_roundtrip() {
425        // One of each entry kind: dictionary, wss-implied literal, verbatim (schemeless) literal.
426        let relays = vec![
427            "wss://relay.damus.io".to_string(),
428            "wss://my.custom.relay/nostr".to_string(),
429            "r1".to_string(),
430        ];
431        let token = new_token();
432        let url = encode_invite_url(&relays, &token);
433        let (parsed_relays, parsed_token) = parse_invite_url(&url).unwrap();
434        assert_eq!(parsed_token, token);
435        assert_eq!(parsed_relays, relays);
436    }
437
438    #[test]
439    fn v1_json_fragment_still_parses() {
440        // Links minted before v2 live in chats forever — the old JSON format must keep parsing.
441        let token = new_token();
442        let json = format!(
443            "{{\"v\":1,\"relays\":[\"wss://r1\",\"wss://r2\"],\"t\":\"{}\"}}",
444            crate::simd::hex::bytes_to_hex_32(&token)
445        );
446        let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(json.as_bytes());
447        let (relays, parsed) = parse_invite_url(&format!("{INVITE_URL_BASE}#{frag}")).unwrap();
448        assert_eq!(parsed, token);
449        assert_eq!(relays, vec!["wss://r1".to_string(), "wss://r2".to_string()]);
450    }
451
452    #[test]
453    fn v2_unknown_dictionary_id_is_skipped_not_fatal() {
454        // Forward-compat: an id appended by a NEWER build must not brick the link on an old
455        // client — the remaining entries still bootstrap.
456        let token = new_token();
457        let mut payload = vec![2u8, 0, 2, 250, 1]; // count=2: unknown id 250, then id 1
458        payload.extend_from_slice(&token);
459        let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&payload);
460        let (relays, parsed) = parse_invite_url(&frag).unwrap();
461        assert_eq!(parsed, token);
462        assert_eq!(relays, vec!["wss://jskitty.com/nostr".to_string()]);
463    }
464
465    #[test]
466    fn v2_garbage_and_truncations_error_without_panic() {
467        for frag_bytes in [
468            vec![2u8],               // just the version byte
469            vec![2u8, 1],            // default flag, no token
470            vec![2u8, 0, 1, 0, 200], // literal claims 200 bytes, has none
471            vec![2u8, 1, 0xAA],      // token wrong length
472            vec![9u8, 1, 2, 3],      // unknown version byte
473        ] {
474            let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&frag_bytes);
475            assert!(parse_invite_url(&frag).is_err());
476        }
477    }
478
479    #[test]
480    fn bundle_round_trips_with_token() {
481        let mut c = Community::create("Cool Place", "general", vec!["wss://r1".into()]);
482        c.description = Some("the coolest".into());
483        let token = new_token();
484        let event = build_public_invite_event(&c, &token, None, None, None).unwrap();
485
486        // Posted under the token-derived coordinate.
487        assert_eq!(event.pubkey, signer_pubkey(&token));
488        assert_eq!(find_tag(&event, "d").as_deref(), Some(locator_hex(&token).as_str()));
489
490        let bundle = parse_public_invite_event(&event, &token).unwrap();
491        assert_eq!(bundle.preview.name, "Cool Place");
492        assert_eq!(bundle.preview.description.as_deref(), Some("the coolest"));
493        // The join material actually works: reconstruct a member and read the keys.
494        let member = super::super::invite::accept_invite(&bundle.join).unwrap();
495        assert_eq!(member.id, c.id);
496        assert_eq!(member.channels[0].key.as_bytes(), c.channels[0].key.as_bytes());
497    }
498
499    #[test]
500    fn tombstone_replaces_the_bundle_at_the_same_coordinate() {
501        // Revoke overwrites the live bundle with a tombstone at the SAME coordinate so relays that ignore
502        // `a`-tag deletions still replace it (replaceable-event semantics) → the browser preview dies.
503        let c = Community::create("HQ", "general", vec!["wss://r1".into()]);
504        let token = new_token();
505        let bundle = build_public_invite_event(&c, &token, None, None, None).unwrap();
506        let tomb = build_public_invite_tombstone(&token).unwrap();
507        // Same (kind, pubkey, d-tag) → a relay replaces the bundle with the tombstone.
508        assert_eq!(tomb.kind, bundle.kind);
509        assert_eq!(tomb.pubkey, bundle.pubkey);
510        assert_eq!(find_tag(&tomb, "d"), find_tag(&bundle, "d"));
511        // Empty content + the revoked subkind → a fetcher gets an explicit "revoked" verdict, not a vague
512        // decrypt failure (so the preview page can show "this invite was revoked" instantly).
513        assert!(tomb.content.is_empty());
514        assert!(
515            matches!(parse_public_invite_event(&tomb, &token), Err(PublicInviteError::Revoked)),
516            "tombstone parses as an explicit Revoked, not a bundle",
517        );
518    }
519
520    #[test]
521    fn wrong_token_cannot_read_bundle() {
522        let c = Community::create("HQ", "general", vec![]);
523        let token = new_token();
524        let event = build_public_invite_event(&c, &token, None, None, None).unwrap();
525
526        // A different token derives a different signer pubkey → rejected before decrypt.
527        let other = new_token();
528        let err = parse_public_invite_event(&event, &other);
529        assert!(matches!(err, Err(PublicInviteError::UnexpectedSigner)), "got {err:?}");
530    }
531
532    #[test]
533    fn impostor_at_locator_is_rejected() {
534        // An attacker posts their OWN event at the right locator d-tag, signed by their
535        // own key. A joiner with the token must reject it (author != token signer).
536        let c = Community::create("HQ", "general", vec![]);
537        let token = new_token();
538        let attacker = Keys::generate();
539        let impostor = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "x")
540            .tags([
541                Tag::identifier(locator_hex(&token)),
542                Tag::custom(TagKind::Custom(TAG_SUBKIND.into()), [VSK_PUBLIC_INVITE.to_string()]),
543                Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]),
544            ])
545            .sign_with_keys(&attacker)
546            .unwrap();
547        let _ = &c;
548        assert!(matches!(
549            parse_public_invite_event(&impostor, &token),
550            Err(PublicInviteError::UnexpectedSigner)
551        ));
552    }
553
554    #[test]
555    fn expiry_is_reported() {
556        let c = Community::create("HQ", "general", vec![]);
557        let token = new_token();
558        let event = build_public_invite_event(&c, &token, Some(1000), None, None).unwrap();
559        let bundle = parse_public_invite_event(&event, &token).unwrap();
560        assert!(!bundle.is_expired(999));
561        assert!(bundle.is_expired(1000), "expiry is inclusive");
562        assert!(bundle.is_expired(2000));
563    }
564
565    #[test]
566    fn url_round_trips() {
567        let relays = vec!["wss://a".to_string(), "wss://b".to_string()];
568        let token = new_token();
569        let url = encode_invite_url(&relays, &token);
570        assert!(url.starts_with(INVITE_URL_BASE));
571        assert!(url.contains('#'));
572        let (r, t) = parse_invite_url(&url).unwrap();
573        assert_eq!(r, relays);
574        assert_eq!(t, token);
575        // A bare fragment (no scheme/host) also parses.
576        let frag = url.rsplit_once('#').unwrap().1;
577        assert_eq!(parse_invite_url(frag).unwrap().1, token);
578    }
579
580    #[test]
581    fn malformed_url_errors() {
582        assert!(parse_invite_url("https://vectorapp.io/invite#").is_err());
583        assert!(parse_invite_url("https://vectorapp.io/invite#not!!base64").is_err());
584    }
585
586    #[test]
587    fn url_with_too_many_relays_is_rejected() {
588        // A hostile link can't fan out unbounded relay connections on preview/accept.
589        let token = new_token();
590        // v2 minting CAPS the bootstrap set (the bundle carries the authoritative relays)…
591        let many: Vec<String> = (0..MAX_URL_RELAYS + 1).map(|i| format!("wss://r{i}")).collect();
592        let (relays, _) = parse_invite_url(&encode_invite_url(&many, &token)).unwrap();
593        assert!(relays.len() <= MAX_V2_BOOTSTRAP_RELAYS);
594        // …a hand-crafted v2 fragment claiming an absurd count is refused outright…
595        let mut payload = vec![URL_V2, 0, (MAX_URL_RELAYS + 1) as u8];
596        payload.extend_from_slice(&token);
597        let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&payload);
598        assert!(parse_invite_url(&frag).is_err());
599        // …and v1 JSON fragments keep their original cap.
600        let json = format!(
601            "{{\"v\":1,\"relays\":[{}],\"t\":\"{}\"}}",
602            (0..MAX_URL_RELAYS + 1).map(|i| format!("\"wss://r{i}\"")).collect::<Vec<_>>().join(","),
603            crate::simd::hex::bytes_to_hex_32(&token)
604        );
605        let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(json.as_bytes());
606        assert!(parse_invite_url(&frag).is_err());
607    }
608}