Skip to main content

vector_core/community/v2/
invite.rs

1//! CORD-05 Invites — how members are handed the keys that make them members.
2//!
3//! Two delivery lanes share one payload, the [`CommunityInvite`] **bundle**:
4//!   - **Public link** — the bundle rides relays as an addressable event
5//!     ([`kind::INVITE_BUNDLE`], empty `d`) authored by a throwaway per-link
6//!     keypair, encrypted under a key nobody on the network holds (derived off
7//!     the link's 16-byte unlock token). The link is `(naddr, #fragment)`: the
8//!     naddr is a bare public locator, the fragment carries the token + bootstrap
9//!     relays and never reaches a server. Only the *creator* (holder of the
10//!     link_signer secret, synced in their [`InviteList`]) can refresh or
11//!     tombstone the coordinate, so a link-holder can join but never squat or
12//!     kill the link (§2).
13//!   - **Direct invite** — when the invitee is a known npub the machinery drops
14//!     away: the bundle giftwraps straight to them as a STANDARD NIP-59 wrap
15//!     ([`build_direct_invite`], §6), not the reversed stream wrap of CORD-01.
16//!
17//! The inviter's identity is irrelevant to trust: the `community_id`
18//! self-certifies the owner (CORD-02 A.4), so no bundle can smuggle a false
19//! owner or a fake key for a real Community. Every bundle passes [`CommunityInvite::validate`]
20//! before it is trusted, whichever lane carried it.
21
22use 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
38/// Hostile-bundle bound: a bundle is attacker-crafted input reached by following
39/// a link, so reject one carrying more Channels than a Community could sanely
40/// hold before allocating on its claims (CORD-05 §1).
41pub const MAX_BUNDLE_CHANNELS: usize = 256;
42
43/// Sanity ceiling on a bundle's `root_epoch` / channel epochs. Not a commitment
44/// (epochs aren't owner-signed), just a bound so attacker-set values can't push a
45/// later `epoch + 1` toward overflow. `2^40` is astronomically above any real
46/// rotation count.
47pub const MAX_BUNDLE_EPOCH: u64 = 1 << 40;
48
49/// The fragment format byte, which also selects the relay-dictionary generation
50/// (CORD-05 §3). Bumping it re-labels the dictionary universe.
51pub const FRAGMENT_VERSION: u8 = 4;
52
53/// The fragment carries at most this many bootstrap relays — it only needs to
54/// *find* the bundle, which then carries the authoritative set (CORD-05 §3).
55pub const MAX_BOOTSTRAP_RELAYS: usize = 3;
56
57/// `flags` bit 0: the stock set is in use, so zero relay bytes follow.
58const FLAG_STOCK_SET: u8 = 0x01;
59
60/// The stock relay dictionary, generation 4 — four primaries every client knows,
61/// referenced by a single byte (id = index + 1). Both Vector and Soapbox ship it
62/// identically, so an invite minted by either opens in the other. Append-only:
63/// growing it is a new generation, editing an entry re-labels existing links.
64const RELAY_DICT: [&str; 4] = [
65    "wss://jskitty.com/nostr",       // id 1 (Vector)
66    "wss://asia.vectorapp.io/nostr", // id 2 (Vector)
67    "wss://relay.ditto.pub",         // id 3 (Soapbox)
68    "wss://relay.dreamith.to",       // id 4 (Soapbox)
69];
70
71// ── Errors ───────────────────────────────────────────────────────────────────
72
73/// Errors from the invite layer.
74#[derive(Debug)]
75pub enum InviteError {
76    Json(String),
77    /// A hex field wasn't 32 valid bytes.
78    BadHex(&'static str),
79    /// More Channels than [`MAX_BUNDLE_CHANNELS`].
80    TooManyChannels(usize),
81    /// `(owner, owner_salt)` fail to reproduce `community_id` (CORD-02 A.4).
82    OwnerMismatch,
83    /// A malformed invite fragment (base64, truncation, trailing bytes, caps).
84    BadFragment(&'static str),
85    /// A fragment version this client won't decode (legacy or future).
86    BadVersion(u8),
87    /// A link/naddr that isn't a recognizable invite coordinate.
88    BadLink(&'static str),
89    /// A bundle event failed a wire gate: wrong kind, wrong author, bad
90    /// signature, or an unknown/missing `vsk` marker.
91    BadEvent(&'static str),
92    /// NIP-44 / NIP-19 / signing failure.
93    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// ── 1. The Bundle ─────────────────────────────────────────────────────────────
115
116/// One granted Channel inside a bundle (CORD-05 §1). Public Channels derive from
117/// the `community_root`; a private Channel's independent `key` travels here.
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct ChannelGrant {
120    /// Channel id (32-byte hex).
121    pub id: String,
122    /// Channel key (32-byte hex).
123    pub key: String,
124    pub epoch: u64,
125    pub name: String,
126}
127
128/// The `CommunityInvite` bundle (CORD-05 §1). Field names are wire-frozen and
129/// shared with Soapbox/Armada; a rename is a silent cross-client join failure.
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct CommunityInvite {
132    /// `sha256("concord/community" || owner || owner_salt)` — self-certifies the owner.
133    pub community_id: String,
134    /// Owner x-only pubkey (32-byte hex).
135    pub owner: String,
136    /// Owner salt (32-byte hex).
137    pub owner_salt: String,
138    /// The base access key (32-byte hex) at `root_epoch`.
139    pub community_root: String,
140    pub root_epoch: u64,
141    /// The Control Plane's signer pubkey at `root_epoch` (CORD-02 §5):
142    /// subscribe, verify, read — never write. Absent = a legacy, pre-split
143    /// Community (fold Control at the legacy address instead, CORD-06 §3).
144    /// Taken on trust — it derives from a secret the joiner never holds, so
145    /// nothing in the bundle can prove it; a wrong one is eclipse-class
146    /// self-harm by the inviter, never forged authority (CORD-05 §1).
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub control_pk: Option<String>,
149    /// ABSENT, not empty, when the bundle vends no channel keys — armada omits it
150    /// (and re-fills it defensively on read). A required Vec here rejects the whole
151    /// bundle, which is a JOIN failure, not just a stale read.
152    #[serde(default)]
153    pub channels: Vec<ChannelGrant>,
154    #[serde(default)]
155    pub relays: Vec<String>,
156    /// Preview name so a parked invite renders; the Control fold is the authority.
157    pub name: String,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub icon: Option<ImageRef>,
160    /// Optional, unix **ms**: past it the preview still renders, joining refuses.
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub expires_at: Option<u64>,
163    /// Optional attribution, echoed in the joiner's Guestbook Join (CORD-05 §1).
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub creator_npub: Option<String>,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub label: Option<String>,
168    /// Unknown fields round-trip verbatim (CORD-02 §6) — carries other clients'
169    /// bundle extensions (`held_roots`, `refounder`, …) through untouched.
170    #[serde(flatten)]
171    pub extra: serde_json::Map<String, serde_json::Value>,
172}
173
174impl CommunityInvite {
175    /// Parse + bound + validate a decrypted bundle, whichever lane carried it:
176    /// truncate `relays` to the Community cap, reject an over-count of Channels
177    /// before trusting it, and verify the owner commitment.
178    pub fn from_bundle_json(json: &str) -> Result<Self, InviteError> {
179        let mut bundle: CommunityInvite = serde_json::from_str(json).map_err(|e| InviteError::Json(e.to_string()))?;
180        if bundle.channels.len() > MAX_BUNDLE_CHANNELS {
181            return Err(InviteError::TooManyChannels(bundle.channels.len()));
182        }
183        bundle.relays = cap_relays(std::mem::take(&mut bundle.relays));
184        bundle.validate()?;
185        Ok(bundle)
186    }
187
188    /// The self-certifying owner check (CORD-02 A.4) plus the Channel bound — a
189    /// mismatching bundle is refused, so even a compromised creator can't smuggle
190    /// a false owner or a fake key for a real Community.
191    pub fn validate(&self) -> Result<(), InviteError> {
192        if self.channels.len() > MAX_BUNDLE_CHANNELS {
193            return Err(InviteError::TooManyChannels(self.channels.len()));
194        }
195        // Epochs aren't covered by the community_id commitment, so an attacker can
196        // set them freely. Bound them well below `u64::MAX` (no real community
197        // rotates a trillion times) so a downstream `epoch + 1` can't be pushed near
198        // overflow and a crafted bundle can't derive nonsense addresses.
199        if self.root_epoch > MAX_BUNDLE_EPOCH || self.channels.iter().any(|c| c.epoch > MAX_BUNDLE_EPOCH) {
200            return Err(InviteError::BadFragment("epoch out of range"));
201        }
202        let owner = hex32(&self.owner, "owner")?;
203        let salt = hex32(&self.owner_salt, "owner_salt")?;
204        let cid = hex32(&self.community_id, "community_id")?;
205        if !verify_community_id(&CommunityId(cid), &owner, &salt) {
206            return Err(InviteError::OwnerMismatch);
207        }
208        Ok(())
209    }
210
211    /// Whether the invite's shelf life has run out (`expires_at` is unix ms).
212    /// Deliberately NOT checked at parse: a parked invite still renders past
213    /// expiry, only joining refuses (CORD-05 §1).
214    pub fn expired(&self, now_ms: u64) -> bool {
215        self.expires_at.is_some_and(|e| now_ms > e)
216    }
217}
218
219// ── 2. The bundle event (kind 33301) ──────────────────────────────────────────
220
221/// A fetched bundle coordinate resolves to one of these (CORD-05 §2).
222#[derive(Debug)]
223pub enum BundleState {
224    /// Boxed: the bundle dwarfs the empty `Revoked` variant.
225    Live(Box<CommunityInvite>),
226    /// The link was retired: a fetcher finds the grave instead of keys.
227    Revoked,
228}
229
230/// Build the addressable bundle event `(33301, link_signer, d="")`, marked live.
231/// The content is the §1 bundle NIP-44-encrypted under `bundle_key` (derived off
232/// the link's token — [`super::derive::invite_bundle_key`]), so relays store it
233/// but can never open it.
234pub fn build_bundle_event(
235    link_signer: &Keys,
236    bundle: &CommunityInvite,
237    bundle_key: &[u8; 32],
238) -> Result<Event, InviteError> {
239    let json = serde_json::to_string(bundle).map_err(|e| InviteError::Json(e.to_string()))?;
240    let content = seal_bundle(bundle_key, &json)?;
241    EventBuilder::new(Kind::Custom(kind::INVITE_BUNDLE), content)
242        .tags([d_empty(), vsk_tag(vsk::INVITE_LIVE)])
243        .finalize(link_signer)
244        .map_err(|e| InviteError::Crypto(e.to_string()))
245}
246
247/// Re-post the coordinate as a revocation tombstone (CORD-05 §2) — signer-signed,
248/// so only the creator, and exactly as durable as the bundle it replaces (unlike
249/// a best-effort relay deletion).
250pub fn build_revocation(link_signer: &Keys) -> Result<Event, InviteError> {
251    EventBuilder::new(Kind::Custom(kind::INVITE_BUNDLE), "")
252        .tags([d_empty(), vsk_tag(vsk::INVITE_REVOKED)])
253        .finalize(link_signer)
254        .map_err(|e| InviteError::Crypto(e.to_string()))
255}
256
257/// Verify + open a fetched bundle event. The primary anti-squat guard is the
258/// FETCH itself — the coordinate `(33301, link_signer, "")` means a different
259/// author is a different coordinate, so a squatter's spam never matches the
260/// filter — and this re-checks `event.pubkey == expected_signer` as a
261/// belt-and-suspenders against a relay handing back a foreign event. Gates on
262/// the signature, the `vsk` marker, decrypt, and [`CommunityInvite::validate`].
263pub fn parse_bundle_event(
264    event: &Event,
265    expected_signer: &PublicKey,
266    bundle_key: &[u8; 32],
267) -> Result<BundleState, InviteError> {
268    if event.kind.as_u16() != kind::INVITE_BUNDLE {
269        return Err(InviteError::BadEvent("wrong kind"));
270    }
271    if event.pubkey != *expected_signer {
272        return Err(InviteError::BadEvent("author is not the link signer"));
273    }
274    // The coordinate is `(33301, link_signer, "")`. The fetch filters on the author alone (relays
275    // handle an empty `#d` filter inconsistently), so pin the empty `d` here instead.
276    if !first_tag(event, "d").unwrap_or_default().is_empty() {
277        return Err(InviteError::BadEvent("bundle is not at the link's coordinate"));
278    }
279    event.verify().map_err(|_| InviteError::BadEvent("signature invalid"))?;
280
281    match first_tag(event, "vsk").as_deref() {
282        Some(v) if v == vsk::INVITE_REVOKED => return Ok(BundleState::Revoked),
283        Some(v) if v == vsk::INVITE_LIVE => {}
284        _ => return Err(InviteError::BadEvent("unknown or missing bundle marker")),
285    }
286
287    let json = open_bundle(bundle_key, &event.content)?;
288    Ok(BundleState::Live(Box::new(CommunityInvite::from_bundle_json(&json)?)))
289}
290
291// ── 3. The link (naddr + fragment codec) ──────────────────────────────────────
292
293const INVITE_PATH: &str = "/invite/";
294
295/// A parsed invite link: the bundle coordinate's author plus the fragment secrets.
296#[derive(Debug, Clone)]
297pub struct ParsedInviteLink {
298    /// The link signer's pubkey — the bundle coordinate's author.
299    pub link_signer: PublicKey,
300    pub token: [u8; TOKEN_LEN],
301    pub bootstrap_relays: Vec<String>,
302    /// The bare naddr as it appeared in the link.
303    pub naddr: String,
304}
305
306/// The stock relay set (dictionary ids 1..=4, in order) — selected by one flag
307/// so the common invite carries zero relay bytes.
308pub fn stock_relays() -> Vec<String> {
309    RELAY_DICT.iter().map(|s| s.to_string()).collect()
310}
311
312/// Encode the fragment `[version=4][flags][relays?][token:16]` as base64url with
313/// no padding. The stock set costs zero relay bytes (and is exempt from the
314/// 3-relay cap, which applies to explicit entries only); otherwise each relay is
315/// a dictionary-id byte, a `wss://`-implied literal (`0,len,host`), or a verbatim
316/// literal (`255,len,url`) for `ws://` and exotic schemes.
317pub fn encode_fragment(token: &[u8; TOKEN_LEN], relays: &[String]) -> Result<String, InviteError> {
318    let is_stock = relays.len() == RELAY_DICT.len() && relays.iter().zip(RELAY_DICT.iter()).all(|(r, d)| r == d);
319
320    let mut bytes = Vec::with_capacity(2 + TOKEN_LEN + relays.len() * 8);
321    bytes.push(FRAGMENT_VERSION);
322    if is_stock {
323        bytes.push(FLAG_STOCK_SET);
324    } else {
325        bytes.push(0x00);
326        let bounded = &relays[..relays.len().min(MAX_BOOTSTRAP_RELAYS)];
327        bytes.push(bounded.len() as u8);
328        for relay in bounded {
329            if let Some(id) = dict_id(relay) {
330                bytes.push(id);
331            } else if let Some(host) = relay.strip_prefix("wss://") {
332                if host.len() > u8::MAX as usize {
333                    return Err(InviteError::BadFragment("relay host too long"));
334                }
335                bytes.extend_from_slice(&[0x00, host.len() as u8]);
336                bytes.extend_from_slice(host.as_bytes());
337            } else {
338                if relay.len() > u8::MAX as usize {
339                    return Err(InviteError::BadFragment("relay url too long"));
340                }
341                bytes.extend_from_slice(&[0xff, relay.len() as u8]);
342                bytes.extend_from_slice(relay.as_bytes());
343            }
344        }
345    }
346    bytes.extend_from_slice(token);
347    Ok(base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes))
348}
349
350/// Decode a fragment into its token + bootstrap relays. Strict on framing: a
351/// wrong version (legacy OR future), a bad count, or any trailing byte after the
352/// token is fatal; an unknown dictionary id is skipped, not fatal, so the
353/// dictionary can grow without breaking older readers.
354pub fn decode_fragment(fragment: &str) -> Result<([u8; TOKEN_LEN], Vec<String>), InviteError> {
355    let bytes = base64_simd::URL_SAFE_NO_PAD
356        .decode_to_vec(fragment.trim().as_bytes())
357        .map_err(|_| InviteError::BadFragment("not base64url"))?;
358
359    if bytes.len() < 2 {
360        return Err(InviteError::BadFragment("truncated"));
361    }
362    let version = bytes[0];
363    // Reject BOTH lower (legacy, wrong dictionary) and higher (unknown format)
364    // versions rather than decode against a dictionary we can't trust.
365    if version != FRAGMENT_VERSION {
366        return Err(InviteError::BadVersion(version));
367    }
368    let flags = bytes[1];
369    let mut o = 2usize;
370
371    let mut relays = Vec::new();
372    if flags & FLAG_STOCK_SET != 0 {
373        relays = stock_relays();
374    } else {
375        let count = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))? as usize;
376        o += 1;
377        if count > MAX_BOOTSTRAP_RELAYS {
378            return Err(InviteError::BadFragment("too many bootstrap relays"));
379        }
380        for _ in 0..count {
381            let lead = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))?;
382            o += 1;
383            if (1..=254).contains(&lead) {
384                if let Some(url) = dict_url(lead) {
385                    relays.push(url.to_string());
386                }
387                // Unknown dictionary id: skip, non-fatal (the dictionary grows).
388            } else {
389                let len = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))? as usize;
390                o += 1;
391                let end = o.checked_add(len).ok_or(InviteError::BadFragment("truncated"))?;
392                let raw = bytes.get(o..end).ok_or(InviteError::BadFragment("truncated"))?;
393                let text = std::str::from_utf8(raw).map_err(|_| InviteError::BadFragment("relay not utf8"))?;
394                relays.push(if lead == 255 { text.to_string() } else { format!("wss://{text}") });
395                o = end;
396            }
397        }
398    }
399
400    let end = o.checked_add(TOKEN_LEN).ok_or(InviteError::BadFragment("truncated"))?;
401    let raw = bytes.get(o..end).ok_or(InviteError::BadFragment("truncated"))?;
402    let mut token = [0u8; TOKEN_LEN];
403    token.copy_from_slice(raw);
404    if end != bytes.len() {
405        return Err(InviteError::BadFragment("trailing bytes"));
406    }
407    Ok((token, relays))
408}
409
410/// Build the bare naddr for a link signer's bundle coordinate `(33301, pk, "")` —
411/// no identifier bytes, no relay entries (relays travel in the fragment), keeping
412/// it as short as an naddr gets.
413pub fn bundle_naddr(link_signer: &PublicKey) -> Result<String, InviteError> {
414    let coord = nostr_sdk::prelude::nip01::Coordinate {
415        kind: Kind::Custom(kind::INVITE_BUNDLE),
416        public_key: *link_signer,
417        identifier: String::new(),
418    };
419    let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate { coordinate: coord, relays: Vec::new() };
420    nostr_sdk::prelude::nip19::Nip19::Coordinate(n19)
421        .to_bech32()
422        .map_err(|e| InviteError::Crypto(e.to_string()))
423}
424
425/// Build a shareable invite URL on `base` — the base is interchangeable (any
426/// deeplink domain works), only the naddr and fragment are protocol (CORD-05 §2).
427pub fn build_invite_url(
428    base: &str,
429    link_signer: &PublicKey,
430    token: &[u8; TOKEN_LEN],
431    relays: &[String],
432) -> Result<String, InviteError> {
433    let naddr = bundle_naddr(link_signer)?;
434    let fragment = encode_fragment(token, relays)?;
435    Ok(format!("{}{INVITE_PATH}{naddr}#{fragment}", base.trim_end_matches('/')))
436}
437
438/// Parse a full URL (`…/invite/<naddr>#<fragment>`) or the domain-agnostic bare
439/// form (`<naddr>#<fragment>`) into its coordinate author + fragment secrets.
440pub fn parse_invite_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
441    let (locator, fragment) = input.trim().split_once('#').ok_or(InviteError::BadLink("no fragment"))?;
442    if fragment.is_empty() {
443        return Err(InviteError::BadLink("empty fragment"));
444    }
445    // A full URL carries the naddr after `/invite/`; the bare form IS the naddr.
446    let naddr = match locator.find(INVITE_PATH) {
447        Some(i) => locator[i + INVITE_PATH.len()..].trim_end_matches('/'),
448        None => locator.trim_start_matches("nostr:"),
449    };
450    let link_signer = signer_from_naddr(naddr)?;
451    let (token, bootstrap_relays) = decode_fragment(fragment)?;
452    Ok(ParsedInviteLink { link_signer, token, bootstrap_relays, naddr: naddr.to_string() })
453}
454
455// ── 4. The Invite List (kind 13303) ───────────────────────────────────────────
456
457/// One minted link in a creator's private [`InviteList`] (CORD-05 §4). The
458/// `token` is the unlock secret AND the merge key; `signer_sk` is what refreshing
459/// or retiring the bundle needs.
460#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
461pub struct InviteEntry {
462    pub token: String,
463    pub signer_sk: String,
464    pub community_id: String,
465    pub url: String,
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub label: Option<String>,
468    pub created_at: u64,
469    #[serde(skip_serializing_if = "Option::is_none")]
470    pub expires_at: Option<u64>,
471    /// The root epoch this link was minted at. The severance discriminator: a
472    /// containment condemns every entry below its epoch, deterministically on
473    /// every device — no clock comparison can disagree with itself the way two
474    /// devices' clocks can. `None` (a pre-field or old-client mint) reads as 0,
475    /// so an unstamped link is condemned by any containment: fail toward the
476    /// closed door.
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub epoch: Option<u64>,
479    #[serde(flatten)]
480    pub extra: serde_json::Map<String, serde_json::Value>,
481}
482
483/// A retired link: a tombstone always beats an entry, terminally (CORD-05 §4).
484#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
485pub struct InviteTombstone {
486    pub token: String,
487    pub community_id: String,
488    #[serde(flatten)]
489    pub extra: serde_json::Map<String, serde_json::Value>,
490}
491
492/// A creator's Invite List — the kind-13303 replaceable, NIP-44-encrypted to
493/// self. Two clients can serve one npub, so the round-trip discipline applies.
494#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
495pub struct InviteList {
496    #[serde(default)]
497    pub entries: Vec<InviteEntry>,
498    #[serde(default)]
499    pub tombstones: Vec<InviteTombstone>,
500    #[serde(flatten)]
501    pub extra: serde_json::Map<String, serde_json::Value>,
502}
503
504/// Merge two Invite Lists without coordination (CORD-05 §4): the token is the
505/// merge key, an entry is immutable once minted (first-seen wins), tombstones
506/// union, and a tombstone always beats an entry — terminally, so a stale device
507/// can never resurrect a revoked link.
508pub fn merge_invite_lists(a: InviteList, b: InviteList) -> InviteList {
509    use std::collections::BTreeMap;
510
511    let mut entries: BTreeMap<String, InviteEntry> = BTreeMap::new();
512    for e in a.entries.into_iter().chain(b.entries) {
513        entries.entry(e.token.clone()).or_insert(e);
514    }
515    let mut tombstones: BTreeMap<String, InviteTombstone> = BTreeMap::new();
516    for t in a.tombstones.into_iter().chain(b.tombstones) {
517        tombstones.entry(t.token.clone()).or_insert(t);
518    }
519    for token in tombstones.keys() {
520        entries.remove(token);
521    }
522    let mut extra = a.extra;
523    extra.extend(b.extra);
524    InviteList {
525        entries: entries.into_values().collect(),
526        tombstones: tombstones.into_values().collect(),
527        extra,
528    }
529}
530
531/// Build the creator's kind-13303 Invite List event (CORD-05 §4): the document
532/// NIP-44-encrypted to SELF and signed by the creator's real key. Replaceable, one
533/// per creator. On READ the caller MERGES into the local mirror, never replaces.
534pub fn build_invite_list_event(my_keys: &Keys, list: &InviteList) -> Result<Event, InviteError> {
535    use nostr_sdk::prelude::nip44::{encrypt, Version};
536    let json = serde_json::to_string(list).map_err(|e| InviteError::Json(e.to_string()))?;
537    let content = encrypt(my_keys.secret_key(), &my_keys.public_key(), json.as_bytes(), Version::V2).map_err(|e| InviteError::Crypto(e.to_string()))?;
538    EventBuilder::new(Kind::Custom(kind::INVITE_LIST), content)
539        .finalize(my_keys)
540        .map_err(|e| InviteError::Crypto(e.to_string()))
541}
542
543/// Decrypt + parse a kind-13303 event with the creator's own keys. A decrypt/parse
544/// failure MUST be treated as "no news" by the caller — never a clobber of a
545/// populated local list.
546pub fn parse_invite_list_event(event: &Event, my_keys: &Keys) -> Result<InviteList, InviteError> {
547    use nostr_sdk::prelude::nip44::decrypt;
548    if event.kind.as_u16() != kind::INVITE_LIST {
549        return Err(InviteError::BadEvent("not a kind-13303 invite list"));
550    }
551    let json = decrypt(my_keys.secret_key(), &my_keys.public_key(), &event.content).map_err(|e| InviteError::Crypto(e.to_string()))?;
552    serde_json::from_str(&json).map_err(|e| InviteError::Json(e.to_string()))
553}
554
555// ── 5. The Registry (vsk 8) ───────────────────────────────────────────────────
556
557/// Build the vsk-8 Registry entity content (CORD-05 §5): a JSON array of the
558/// creator's live link COORDINATES (link_signer pubkey hex) — never tokens,
559/// URLs, or signing secrets, so members see that links exist without gaining the
560/// ability to use one. The edition wrapping reuses the Control plane
561/// ([`super::control`]); this is only the content shape.
562pub fn build_registry_content(signers: &[PublicKey]) -> String {
563    let hexes: Vec<String> = signers.iter().map(|p| p.to_hex()).collect();
564    serde_json::to_string(&hexes).expect("Vec<String> always serializes")
565}
566
567/// Parse a Registry entity's content back into the link-signer coordinates.
568pub fn parse_registry_content(content: &str) -> Result<Vec<PublicKey>, InviteError> {
569    let hexes: Vec<String> = serde_json::from_str(content).map_err(|e| InviteError::Json(e.to_string()))?;
570    hexes
571        .iter()
572        .map(|h| PublicKey::from_hex(h).map_err(|_| InviteError::BadHex("registry signer")))
573        .collect()
574}
575
576// ── 6. Direct Invites (kind 3313) ─────────────────────────────────────────────
577
578/// Build a Direct Invite (CORD-05 §6): the §1 bundle handed to a known npub as a
579/// STANDARD NIP-59 giftwrap — ephemeral wrap author, the recipient in the `p`
580/// tag, a kind-13 seal signed by the inviter's REAL key (whose verified npub is
581/// what proves who invited), NOT the reversed stream wrap of CORD-01. The wrap
582/// carries the outer `["k","3313"]` index hint (the deliberate exception to the
583/// no-outer-tags rule) plus an optional NIP-40 `expiration` matching the bundle's
584/// `expires_at` (ms → seconds). NIP-59 tweaks the wrap and seal timestamps into
585/// the past.
586pub fn build_direct_invite(
587    inviter_keys: &Keys,
588    recipient: &PublicKey,
589    bundle: &CommunityInvite,
590) -> Result<Event, InviteError> {
591    let json = serde_json::to_string(bundle).map_err(|e| InviteError::Json(e.to_string()))?;
592
593    // The unsigned kind-3313 rumor, authored (claimed) by the inviter.
594    let mut rumor = EventBuilder::new(Kind::Custom(kind::DIRECT_INVITE), json)
595        .custom_created_at(Timestamp::now())
596        .finalize_unsigned_with_id(inviter_keys.public_key());
597    rumor.ensure_id();
598
599    let seal_content = nip44::encrypt(inviter_keys.secret_key(), recipient, rumor.as_json(), nip44::Version::default())
600        .map_err(|e| InviteError::Crypto(e.to_string()))?;
601    let seal = EventBuilder::new(Kind::Seal, seal_content)
602        .custom_created_at(crate::sending::tweaked_timestamp())
603        .finalize(inviter_keys)
604        .map_err(|e| InviteError::Crypto(e.to_string()))?;
605
606    let ephemeral = Keys::generate();
607    let wrap_content = nip44::encrypt(ephemeral.secret_key(), recipient, seal.as_json(), nip44::Version::default())
608        .map_err(|e| InviteError::Crypto(e.to_string()))?;
609    let mut tags = vec![
610        Tag::public_key(*recipient),
611        Tag::custom("k", [kind::DIRECT_INVITE.to_string()]),
612    ];
613    if let Some(ms) = bundle.expires_at {
614        tags.push(Tag::custom("expiration", [(ms / 1000).to_string()]));
615    }
616    EventBuilder::new(Kind::GiftWrap, wrap_content)
617        .tags(tags)
618        .custom_created_at(crate::sending::tweaked_timestamp())
619        .finalize(&ephemeral)
620        .map_err(|e| InviteError::Crypto(e.to_string()))
621}
622
623/// Unwrap a Direct Invite giftwrap addressed to the recipient, returning the
624/// verified inviter + the validated bundle. Peels wrap → seal → rumor and MUST
625/// Schnorr-verify the kind-13 seal (its npub is the only proof of who invited),
626/// then binds `rumor.pubkey == seal.pubkey` (anti-spoof) and gates on the rumor
627/// kind (the outer `k` tag was only ever a hint). Nothing joins on unwrap —
628/// consent is the caller's concern (CORD-05 §6).
629pub fn unwrap_direct_invite(wrap: &Event, recipient_keys: &Keys) -> Result<(PublicKey, CommunityInvite), InviteError> {
630    if wrap.kind != Kind::GiftWrap {
631        return Err(InviteError::BadEvent("not a gift wrap"));
632    }
633    let seal_json = nip44::decrypt(recipient_keys.secret_key(), &wrap.pubkey, &wrap.content)
634        .map_err(|e| InviteError::Crypto(e.to_string()))?;
635    let seal = Event::from_json(&seal_json).map_err(|e| InviteError::Json(e.to_string()))?;
636    if seal.kind != Kind::Seal {
637        return Err(InviteError::BadEvent("inner is not a seal"));
638    }
639    seal.verify().map_err(|_| InviteError::BadEvent("seal signature invalid"))?;
640
641    let rumor_json = nip44::decrypt(recipient_keys.secret_key(), &seal.pubkey, &seal.content)
642        .map_err(|e| InviteError::Crypto(e.to_string()))?;
643    let rumor = UnsignedEvent::from_json(rumor_json.as_bytes()).map_err(|e| InviteError::Json(e.to_string()))?;
644    if rumor.kind.as_u16() != kind::DIRECT_INVITE {
645        return Err(InviteError::BadEvent("rumor is not a direct invite"));
646    }
647    if rumor.pubkey != seal.pubkey {
648        return Err(InviteError::BadEvent("rumor author does not match the seal signer"));
649    }
650    let bundle = CommunityInvite::from_bundle_json(&rumor.content)?;
651    Ok((seal.pubkey, bundle))
652}
653
654// ── Signer-driven twins (bunker / NIP-55): identical wire, identity ops via VectorSigner ──
655
656/// [`build_invite_list_event`] via a [`VectorSigner`]. Self-encrypts to `my_pk` and
657/// signs the 13303 through the signer. `my_pk` must equal `my_public_key()`.
658pub async fn build_invite_list_event_signed<S: crate::signer::VectorSigner + ?Sized>(
659    signer: &S,
660    my_pk: PublicKey,
661    list: &InviteList,
662) -> Result<Event, InviteError> {
663    let json = serde_json::to_string(list).map_err(|e| InviteError::Json(e.to_string()))?;
664    let content = signer.nip44_encrypt_async(&my_pk, &json).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
665    let unsigned = EventBuilder::new(Kind::Custom(kind::INVITE_LIST), content).finalize_unsigned_with_id(my_pk);
666    signer.sign_event_async(unsigned).await.map_err(|e| InviteError::Crypto(e.to_string()))
667}
668
669/// [`parse_invite_list_event`] via a [`VectorSigner`] (self-decrypt to `my_pk`).
670pub async fn parse_invite_list_event_signed<S: crate::signer::VectorSigner + ?Sized>(
671    signer: &S,
672    my_pk: PublicKey,
673    event: &Event,
674) -> Result<InviteList, InviteError> {
675    if event.kind.as_u16() != kind::INVITE_LIST {
676        return Err(InviteError::BadEvent("not a kind-13303 invite list"));
677    }
678    let json = signer.nip44_decrypt_async(&my_pk, &event.content).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
679    serde_json::from_str(&json).map_err(|e| InviteError::Json(e.to_string()))
680}
681
682/// [`build_direct_invite`] via a [`VectorSigner`]: the kind-13 seal's NIP-44 (inviter
683/// → recipient) and signature go through the signer; the ephemeral wrap stays local.
684pub async fn build_direct_invite_signed<S: crate::signer::VectorSigner + ?Sized>(
685    signer: &S,
686    inviter_pk: PublicKey,
687    recipient: &PublicKey,
688    bundle: &CommunityInvite,
689) -> Result<Event, InviteError> {
690    let json = serde_json::to_string(bundle).map_err(|e| InviteError::Json(e.to_string()))?;
691    let mut rumor = EventBuilder::new(Kind::Custom(kind::DIRECT_INVITE), json)
692        .custom_created_at(Timestamp::now())
693        .finalize_unsigned_with_id(inviter_pk);
694    rumor.ensure_id();
695    let rumor_json = rumor.as_json();
696    let seal_content = signer.nip44_encrypt_async(recipient, &rumor_json).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
697    let seal_unsigned = EventBuilder::new(Kind::Seal, seal_content)
698        .custom_created_at(crate::sending::tweaked_timestamp())
699        .finalize_unsigned_with_id(inviter_pk);
700    let seal = signer.sign_event_async(seal_unsigned).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
701    let ephemeral = Keys::generate();
702    let wrap_content = nip44::encrypt(ephemeral.secret_key(), recipient, seal.as_json(), nip44::Version::default())
703        .map_err(|e| InviteError::Crypto(e.to_string()))?;
704    let mut tags = vec![
705        Tag::public_key(*recipient),
706        Tag::custom("k", [kind::DIRECT_INVITE.to_string()]),
707    ];
708    if let Some(ms) = bundle.expires_at {
709        tags.push(Tag::custom("expiration", [(ms / 1000).to_string()]));
710    }
711    EventBuilder::new(Kind::GiftWrap, wrap_content)
712        .tags(tags)
713        .custom_created_at(crate::sending::tweaked_timestamp())
714        .finalize(&ephemeral)
715        .map_err(|e| InviteError::Crypto(e.to_string()))
716}
717
718/// [`unwrap_direct_invite`] via a [`VectorSigner`]: both giftwrap peels decrypt
719/// through the signer. Same seal-verify + author-bind gates as the local path.
720pub async fn unwrap_direct_invite_signed<S: crate::signer::VectorSigner + ?Sized>(
721    signer: &S,
722    wrap: &Event,
723) -> Result<(PublicKey, CommunityInvite), InviteError> {
724    if wrap.kind != Kind::GiftWrap {
725        return Err(InviteError::BadEvent("not a gift wrap"));
726    }
727    let seal_json = signer.nip44_decrypt_async(&wrap.pubkey, &wrap.content).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
728    let seal = Event::from_json(&seal_json).map_err(|e| InviteError::Json(e.to_string()))?;
729    if seal.kind != Kind::Seal {
730        return Err(InviteError::BadEvent("inner is not a seal"));
731    }
732    seal.verify().map_err(|_| InviteError::BadEvent("seal signature invalid"))?;
733    let rumor_json = signer.nip44_decrypt_async(&seal.pubkey, &seal.content).await.map_err(|e| InviteError::Crypto(e.to_string()))?;
734    let rumor = UnsignedEvent::from_json(rumor_json.as_bytes()).map_err(|e| InviteError::Json(e.to_string()))?;
735    if rumor.kind.as_u16() != kind::DIRECT_INVITE {
736        return Err(InviteError::BadEvent("rumor is not a direct invite"));
737    }
738    if rumor.pubkey != seal.pubkey {
739        return Err(InviteError::BadEvent("rumor author does not match the seal signer"));
740    }
741    let bundle = CommunityInvite::from_bundle_json(&rumor.content)?;
742    Ok((seal.pubkey, bundle))
743}
744
745// ── helpers ────────────────────────────────────────────────────────────────────
746
747fn hex32(s: &str, field: &'static str) -> Result<[u8; 32], InviteError> {
748    crate::simd::hex::hex_to_bytes_32_checked(s).ok_or(InviteError::BadHex(field))
749}
750
751fn dict_id(url: &str) -> Option<u8> {
752    RELAY_DICT.iter().position(|u| *u == url).map(|i| (i + 1) as u8)
753}
754
755fn dict_url(id: u8) -> Option<&'static str> {
756    RELAY_DICT.get((id as usize).checked_sub(1)?).copied()
757}
758
759fn d_empty() -> Tag {
760    Tag::custom("d", [""])
761}
762
763fn vsk_tag(value: &str) -> Tag {
764    Tag::custom("vsk", [value])
765}
766
767fn first_tag(event: &Event, name: &str) -> Option<String> {
768    event.tags.iter().find_map(|t| {
769        let s = t.as_slice();
770        (s.len() >= 2 && s[0] == name).then(|| s[1].clone())
771    })
772}
773
774/// The NIP-44 bundle ciphertext under `bundle_key` used directly as the
775/// conversation key (CORD-05 §2 — not an ECDH pair). Standard-base64 payload so
776/// it interoperates with nostr-tools' `nip44.encrypt(json, bundle_key)`.
777pub(crate) fn seal_bundle(bundle_key: &[u8; 32], json: &str) -> Result<String, InviteError> {
778    let ck = ConversationKey::new(*bundle_key);
779    let ct = crate::community::cipher::encrypt_with_random_nonce(&ck, json.as_bytes()).map_err(|e| InviteError::Crypto(e.to_string()))?;
780    Ok(base64_simd::STANDARD.encode_to_string(&ct))
781}
782
783fn open_bundle(bundle_key: &[u8; 32], content: &str) -> Result<String, InviteError> {
784    let ck = ConversationKey::new(*bundle_key);
785    let ct = base64_simd::STANDARD
786        .decode_to_vec(content.as_bytes())
787        .map_err(|e| InviteError::Crypto(e.to_string()))?;
788    let pt = decrypt_to_bytes(&ck, &ct).map_err(|e| InviteError::Crypto(e.to_string()))?;
789    String::from_utf8(pt).map_err(|e| InviteError::Crypto(e.to_string()))
790}
791
792fn signer_from_naddr(naddr: &str) -> Result<PublicKey, InviteError> {
793    let trimmed = naddr.trim().trim_start_matches("nostr:");
794    let n19 = nostr_sdk::prelude::nip19::Nip19::from_bech32(trimmed).map_err(|_| InviteError::BadLink("invalid naddr"))?;
795    match n19 {
796        nostr_sdk::prelude::nip19::Nip19::Coordinate(c)
797            if c.coordinate.kind.as_u16() == kind::INVITE_BUNDLE && c.coordinate.identifier.is_empty() =>
798        {
799            Ok(c.coordinate.public_key)
800        }
801        _ => Err(InviteError::BadLink("naddr is not an invite-bundle coordinate")),
802    }
803}
804
805#[cfg(test)]
806mod tests {
807    use super::super::control::CommunityIdentity;
808    use super::super::derive::invite_bundle_key;
809    use super::*;
810    use crate::community::{random_32, MAX_COMMUNITY_RELAYS};
811
812    fn hex(bytes: &[u8; 32]) -> String {
813        crate::simd::hex::bytes_to_hex_32(bytes)
814    }
815
816    #[test]
817    fn a_bundle_vending_no_channel_keys_omits_the_field_entirely() {
818        // armada omits `channels` when the bundle carries no keys, and re-fills it
819        // on read. Required here, a public-community invite fails to parse at all —
820        // a JOIN failure, not merely a stale read. Optional fields stay absent too.
821        let json = r#"{
822          "community_id":"aa","owner":"bb","owner_salt":"cc",
823          "community_root":"dd","root_epoch":0,"name":"Public Room"
824        }"#;
825        let inv: CommunityInvite = serde_json::from_str(json).expect("absent `channels`/`relays` must parse");
826        assert!(inv.channels.is_empty());
827        assert!(inv.relays.is_empty());
828        assert_eq!(inv.icon, None, "absent optionals are None, not an error");
829        assert_eq!(inv.expires_at, None);
830        assert_eq!(inv.name, "Public Room");
831    }
832
833    /// A valid owner + bundle whose (owner, salt) reproduce its community_id.
834    fn valid_bundle() -> (Keys, CommunityInvite) {
835        let owner = Keys::generate();
836        let id = CommunityIdentity::mint(&owner.public_key());
837        let bundle = CommunityInvite {
838            community_id: hex(&id.community_id.0),
839            owner: hex(&id.owner_xonly),
840            owner_salt: hex(&id.owner_salt),
841            community_root: hex(&random_32()),
842            root_epoch: 0,
843            control_pk: None,
844            channels: vec![],
845            relays: vec!["wss://a.example".into(), "wss://b.example".into()],
846            name: "Test community".into(),
847            icon: None,
848            expires_at: None,
849            creator_npub: None,
850            label: None,
851            extra: Default::default(),
852        };
853        (owner, bundle)
854    }
855
856    fn token16() -> [u8; TOKEN_LEN] {
857        std::array::from_fn(|i| i as u8) // 00,01,..,0f
858    }
859
860    // ── Bundle validate() ──────────────────────────────────────────────────────
861
862    #[test]
863    fn validate_accepts_a_correct_owner_salt_id_triple() {
864        let (_owner, bundle) = valid_bundle();
865        assert!(bundle.validate().is_ok());
866    }
867
868    #[test]
869    fn validate_rejects_a_forged_owner() {
870        let (_owner, mut bundle) = valid_bundle();
871        // Attacker key over the REAL community id → second-preimage, must fail.
872        let attacker = Keys::generate();
873        bundle.owner = hex(&attacker.public_key().to_bytes());
874        assert!(matches!(bundle.validate(), Err(InviteError::OwnerMismatch)));
875    }
876
877    #[test]
878    fn from_json_rejects_over_the_channel_cap() {
879        let (_owner, mut bundle) = valid_bundle();
880        bundle.channels = (0..MAX_BUNDLE_CHANNELS + 1)
881            .map(|_| ChannelGrant { id: hex(&random_32()), key: hex(&random_32()), epoch: 0, name: "x".into() })
882            .collect();
883        let json = serde_json::to_string(&bundle).unwrap();
884        assert!(matches!(
885            CommunityInvite::from_bundle_json(&json),
886            Err(InviteError::TooManyChannels(n)) if n == MAX_BUNDLE_CHANNELS + 1
887        ));
888    }
889
890    #[test]
891    fn from_json_truncates_relays_to_the_community_cap() {
892        let (_owner, mut bundle) = valid_bundle();
893        bundle.relays = (0..MAX_COMMUNITY_RELAYS + 3).map(|i| format!("wss://r{i}.example")).collect();
894        let json = serde_json::to_string(&bundle).unwrap();
895        let parsed = CommunityInvite::from_bundle_json(&json).unwrap();
896        assert_eq!(parsed.relays.len(), MAX_COMMUNITY_RELAYS);
897    }
898
899    #[test]
900    fn unknown_bundle_fields_round_trip() {
901        // Other clients' extensions (held_roots, refounder, future) survive a
902        // parse → serialize cycle untouched (CORD-02 §6).
903        let (_owner, bundle) = valid_bundle();
904        let mut value = serde_json::to_value(&bundle).unwrap();
905        let obj = value.as_object_mut().unwrap();
906        obj.insert("held_roots".into(), serde_json::json!([{ "epoch": 2, "key": "ab" }]));
907        obj.insert("refounder".into(), serde_json::json!("deadbeef"));
908        obj.insert("future_field".into(), serde_json::json!({ "deep": [1, 2] }));
909        let json = serde_json::to_string(&value).unwrap();
910
911        let parsed = CommunityInvite::from_bundle_json(&json).unwrap();
912        let out: serde_json::Value = serde_json::from_str(&serde_json::to_string(&parsed).unwrap()).unwrap();
913        assert_eq!(out["held_roots"][0]["epoch"], 2);
914        assert_eq!(out["refounder"], "deadbeef");
915        assert_eq!(out["future_field"]["deep"][1], 2);
916    }
917
918    #[test]
919    fn expiry_is_a_join_gate_not_a_render_gate() {
920        let (_owner, mut bundle) = valid_bundle();
921        bundle.expires_at = Some(1_000_000);
922        assert!(!bundle.expired(999_999));
923        assert!(bundle.expired(1_000_001));
924    }
925
926    // ── Fragment codec GOLDEN VECTORS (hand-computed base64url) ──────────────────
927
928    #[test]
929    fn fragment_golden_stock_set() {
930        // [0x04 version][0x01 stock flag][token 00..0f] = 18 bytes → base64url:
931        //   04 01 00 | 01 02 03 | 04 05 06 | 07 08 09 | 0a 0b 0c | 0d 0e 0f
932        //   B A E A    A Q I D    B A U G    B w g J    C g s M    D Q 4 P
933        let token = token16();
934        let frag = encode_fragment(&token, &stock_relays()).unwrap();
935        assert_eq!(frag, "BAEAAQIDBAUGBwgJCgsMDQ4P");
936        let (t, relays) = decode_fragment(&frag).unwrap();
937        assert_eq!(t, token);
938        assert_eq!(relays, stock_relays());
939    }
940
941    #[test]
942    fn fragment_golden_dictionary_id_mix() {
943        // [04][00 flags][02 count][02 dict-id][04 dict-id][token] = 21 bytes:
944        //   04 00 02 | 02 04 00 | 01 02 03 | 04 05 06 | 07 08 09 | 0a 0b 0c | 0d 0e 0f
945        //   B A A C    A g Q A    A Q I D    B A U G    B w g J    C g s M    D Q 4 P
946        let token = token16();
947        let relays = vec![RELAY_DICT[1].to_string(), RELAY_DICT[3].to_string()];
948        let frag = encode_fragment(&token, &relays).unwrap();
949        assert_eq!(frag, "BAACAgQAAQIDBAUGBwgJCgsMDQ4P");
950        let (t, out) = decode_fragment(&frag).unwrap();
951        assert_eq!(t, token);
952        assert_eq!(out, relays);
953    }
954
955    #[test]
956    fn fragment_golden_wss_implied_literal() {
957        // [04][00][01 count][00 lead=wss-implied][03 len]["x.y"=78 2e 79][token] = 24 bytes:
958        //   04 00 01 | 00 03 78 | 2e 79 00 | 01 02 03 | 04 05 06 | 07 08 09 | 0a 0b 0c | 0d 0e 0f
959        //   B A A B    A A N 4    L n k A    A Q I D    B A U G    B w g J    C g s M    D Q 4 P
960        let token = token16();
961        let relays = vec!["wss://x.y".to_string()];
962        let frag = encode_fragment(&token, &relays).unwrap();
963        assert_eq!(frag, "BAABAAN4LnkAAQIDBAUGBwgJCgsMDQ4P");
964        let (t, out) = decode_fragment(&frag).unwrap();
965        assert_eq!(t, token);
966        assert_eq!(out, relays);
967    }
968
969    #[test]
970    fn fragment_golden_verbatim_literal() {
971        // [04][00][01][ff lead=verbatim][06 len]["ws://h"=77 73 3a 2f 2f 68][token] = 27 bytes:
972        //   04 00 01 | ff 06 77 | 73 3a 2f | 2f 68 00 | 01 02 03 | 04 05 06 | 07 08 09 | 0a 0b 0c | 0d 0e 0f
973        //   B A A B    _ w Z 3    c z o v    L 2 g A    A Q I D    B A U G    B w g J    C g s M    D Q 4 P
974        let token = token16();
975        let relays = vec!["ws://h".to_string()];
976        let frag = encode_fragment(&token, &relays).unwrap();
977        assert_eq!(frag, "BAAB_wZ3czovL2gAAQIDBAUGBwgJCgsMDQ4P");
978        let (t, out) = decode_fragment(&frag).unwrap();
979        assert_eq!(t, token);
980        assert_eq!(out, relays);
981    }
982
983    #[test]
984    fn fragment_rejects_wrong_version_both_directions() {
985        let token = token16();
986        for bad in [3u8, 5u8] {
987            let mut bytes = vec![bad, FLAG_STOCK_SET];
988            bytes.extend_from_slice(&token);
989            let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
990            assert!(matches!(decode_fragment(&frag), Err(InviteError::BadVersion(v)) if v == bad));
991        }
992    }
993
994    #[test]
995    fn fragment_rejects_trailing_bytes() {
996        let token = token16();
997        let mut bytes = vec![FRAGMENT_VERSION, FLAG_STOCK_SET];
998        bytes.extend_from_slice(&token);
999        bytes.push(0xff); // one byte past the token
1000        let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
1001        assert!(matches!(decode_fragment(&frag), Err(InviteError::BadFragment("trailing bytes"))));
1002    }
1003
1004    #[test]
1005    fn fragment_rejects_count_over_three() {
1006        let token = token16();
1007        let mut bytes = vec![FRAGMENT_VERSION, 0x00, 0x04]; // count 4 > cap
1008        bytes.extend_from_slice(&[1, 2, 3, 4]);
1009        bytes.extend_from_slice(&token);
1010        let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
1011        assert!(matches!(decode_fragment(&frag), Err(InviteError::BadFragment("too many bootstrap relays"))));
1012    }
1013
1014    #[test]
1015    fn fragment_skips_an_unknown_dictionary_id() {
1016        let token = token16();
1017        // count 1, dict id 200 (unknown gen-4 id) → skipped, not fatal.
1018        let mut bytes = vec![FRAGMENT_VERSION, 0x00, 0x01, 200];
1019        bytes.extend_from_slice(&token);
1020        let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
1021        let (t, relays) = decode_fragment(&frag).unwrap();
1022        assert_eq!(t, token);
1023        assert!(relays.is_empty());
1024    }
1025
1026    #[test]
1027    fn fragment_caps_bootstrap_relays_at_three() {
1028        let token = token16();
1029        let relays: Vec<String> = (0..4).map(|i| format!("wss://r{i}.example")).collect();
1030        let (_t, out) = decode_fragment(&encode_fragment(&token, &relays).unwrap()).unwrap();
1031        assert_eq!(out.len(), MAX_BOOTSTRAP_RELAYS);
1032    }
1033
1034    // ── 33301 bundle event ──────────────────────────────────────────────────────
1035
1036    #[test]
1037    fn bundle_event_round_trips_live() {
1038        let (_owner, bundle) = valid_bundle();
1039        let link = Keys::generate();
1040        let token = random_32();
1041        let key = invite_bundle_key(&token[..TOKEN_LEN].try_into().unwrap());
1042        let event = build_bundle_event(&link, &bundle, &key).unwrap();
1043        assert_eq!(event.pubkey, link.public_key());
1044
1045        match parse_bundle_event(&event, &link.public_key(), &key).unwrap() {
1046            BundleState::Live(b) => {
1047                assert_eq!(b.community_id, bundle.community_id);
1048                assert_eq!(b.name, "Test community");
1049            }
1050            BundleState::Revoked => panic!("expected Live"),
1051        }
1052    }
1053
1054    #[test]
1055    fn revocation_reads_as_revoked() {
1056        let link = Keys::generate();
1057        let tomb = build_revocation(&link).unwrap();
1058        let key = invite_bundle_key(&[0u8; TOKEN_LEN]);
1059        assert!(matches!(parse_bundle_event(&tomb, &link.public_key(), &key), Ok(BundleState::Revoked)));
1060    }
1061
1062    #[test]
1063    fn bundle_event_wrong_key_fails() {
1064        let (_owner, bundle) = valid_bundle();
1065        let link = Keys::generate();
1066        let key = invite_bundle_key(&[7u8; TOKEN_LEN]);
1067        let event = build_bundle_event(&link, &bundle, &key).unwrap();
1068        let wrong = invite_bundle_key(&[8u8; TOKEN_LEN]);
1069        assert!(parse_bundle_event(&event, &link.public_key(), &wrong).is_err());
1070    }
1071
1072    #[test]
1073    fn bundle_event_from_a_different_author_is_rejected() {
1074        // The coordinate is the anti-squat guard; the author re-check catches a
1075        // relay handing back a squatter's event at the same d.
1076        let (_owner, bundle) = valid_bundle();
1077        let real = Keys::generate();
1078        let squatter = Keys::generate();
1079        let key = invite_bundle_key(&[3u8; TOKEN_LEN]);
1080        let event = build_bundle_event(&squatter, &bundle, &key).unwrap();
1081        assert!(matches!(
1082            parse_bundle_event(&event, &real.public_key(), &key),
1083            Err(InviteError::BadEvent("author is not the link signer"))
1084        ));
1085    }
1086
1087    /// The fetch filters on the author alone (an empty `#d` filter is answered inconsistently by
1088    /// relays), so the empty `d` MUST be pinned here — otherwise the same signer's event at any
1089    /// other `d` would be accepted as the bundle.
1090    #[test]
1091    fn bundle_event_at_a_non_empty_d_is_rejected() {
1092        let (_owner, bundle) = valid_bundle();
1093        let link = Keys::generate();
1094        let key = invite_bundle_key(&[9u8; TOKEN_LEN]);
1095        let json = serde_json::to_string(&bundle).unwrap();
1096        let content = seal_bundle(&key, &json).unwrap();
1097        let event = EventBuilder::new(Kind::Custom(kind::INVITE_BUNDLE), content)
1098            .tags([Tag::identifier("elsewhere"), vsk_tag(vsk::INVITE_LIVE)])
1099            .finalize(&link)
1100            .unwrap();
1101        assert!(matches!(
1102            parse_bundle_event(&event, &link.public_key(), &key),
1103            Err(InviteError::BadEvent("bundle is not at the link's coordinate"))
1104        ));
1105    }
1106
1107    #[test]
1108    fn bundle_event_with_a_tampered_signature_is_rejected() {
1109        let (_owner, bundle) = valid_bundle();
1110        let link = Keys::generate();
1111        let other = Keys::generate();
1112        let key = invite_bundle_key(&[4u8; TOKEN_LEN]);
1113        let event = build_bundle_event(&link, &bundle, &key).unwrap();
1114        // Swap the author to `other` (its signature no longer matches the id).
1115        let mut json: serde_json::Value = serde_json::from_str(&event.as_json()).unwrap();
1116        json["pubkey"] = serde_json::Value::String(other.public_key().to_hex());
1117        let Ok(forged) = Event::from_json(json.to_string()) else { return };
1118        assert!(matches!(
1119            parse_bundle_event(&forged, &other.public_key(), &key),
1120            Err(InviteError::BadEvent("signature invalid"))
1121        ));
1122    }
1123
1124    // ── Invite List merge ───────────────────────────────────────────────────────
1125
1126    fn entry(token: &str, community: &str) -> InviteEntry {
1127        InviteEntry {
1128            token: token.into(),
1129            signer_sk: "bb".repeat(32),
1130            community_id: community.into(),
1131            url: "https://x/invite/naddr1xyz#frag".into(),
1132            label: None,
1133            created_at: 1000,
1134            expires_at: None,
1135            epoch: None,
1136            extra: Default::default(),
1137        }
1138    }
1139
1140    #[test]
1141    fn invite_list_merge_entries_immutable_tombstone_wins_terminally() {
1142        let e = entry(&"aa".repeat(16), &"cc".repeat(32));
1143        let a = merge_invite_lists(InviteList::default(), InviteList { entries: vec![e.clone()], ..Default::default() });
1144        assert_eq!(a.entries.len(), 1);
1145
1146        // Re-merging a mutated entry under the same token can't change it.
1147        let mut mutated = e.clone();
1148        mutated.label = Some("changed".into());
1149        let still = merge_invite_lists(a.clone(), InviteList { entries: vec![mutated], ..Default::default() });
1150        assert_eq!(still.entries[0].label, None);
1151
1152        // Tombstone beats the entry.
1153        let tomb = InviteTombstone { token: e.token.clone(), community_id: e.community_id.clone(), extra: Default::default() };
1154        let b = merge_invite_lists(a, InviteList { tombstones: vec![tomb], ..Default::default() });
1155        assert!(b.entries.is_empty());
1156        assert_eq!(b.tombstones.len(), 1);
1157
1158        // A stale device re-merging the entry can't resurrect the revoked link.
1159        let c = merge_invite_lists(b, InviteList { entries: vec![e], ..Default::default() });
1160        assert!(c.entries.is_empty());
1161    }
1162
1163    #[test]
1164    fn invite_list_tombstones_union() {
1165        let t1 = InviteTombstone { token: "11".repeat(16), community_id: "aa".repeat(32), extra: Default::default() };
1166        let t2 = InviteTombstone { token: "22".repeat(16), community_id: "bb".repeat(32), extra: Default::default() };
1167        let merged = merge_invite_lists(
1168            InviteList { tombstones: vec![t1], ..Default::default() },
1169            InviteList { tombstones: vec![t2], ..Default::default() },
1170        );
1171        assert_eq!(merged.tombstones.len(), 2);
1172    }
1173
1174    #[test]
1175    fn invite_list_unknown_fields_round_trip() {
1176        let wire = r#"{"entries":[],"tombstones":[],"schema":"future","note":{"x":1}}"#;
1177        let list: InviteList = serde_json::from_str(wire).unwrap();
1178        let out: serde_json::Value = serde_json::from_str(&serde_json::to_string(&list).unwrap()).unwrap();
1179        assert_eq!(out["schema"], "future");
1180        assert_eq!(out["note"]["x"], 1);
1181    }
1182
1183    // ── Direct invite ───────────────────────────────────────────────────────────
1184
1185    #[test]
1186    fn direct_invite_round_trips_to_inviter_and_bundle() {
1187        let inviter = Keys::generate();
1188        let recipient = Keys::generate();
1189        let (_owner, bundle) = valid_bundle();
1190
1191        let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1192        assert_eq!(wrap.kind, Kind::GiftWrap);
1193        assert_ne!(wrap.pubkey, inviter.public_key(), "wrap author is ephemeral");
1194        assert!(wrap.tags.iter().any(|t| {
1195            let s = t.as_slice();
1196            s.len() >= 2 && s[0] == "k" && s[1] == kind::DIRECT_INVITE.to_string()
1197        }));
1198
1199        let (sender, out) = unwrap_direct_invite(&wrap, &recipient).unwrap();
1200        assert_eq!(sender, inviter.public_key());
1201        assert_eq!(out.community_id, bundle.community_id);
1202        assert_eq!(out.name, "Test community");
1203    }
1204
1205    #[test]
1206    fn direct_invite_rejects_a_seal_claiming_a_false_inviter() {
1207        // The verify Armada skips: a seal whose pubkey was swapped to Y but signed
1208        // by X has an invalid signature — the Schnorr check must catch it.
1209        let inviter = Keys::generate();
1210        let claimed = Keys::generate();
1211        let recipient = Keys::generate();
1212        let (_owner, bundle) = valid_bundle();
1213
1214        // Build a normal invite, then rebuild the seal with a swapped pubkey.
1215        let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1216        let seal_json = nip44::decrypt(recipient.secret_key(), &wrap.pubkey, &wrap.content).unwrap();
1217        let mut seal_val: serde_json::Value = serde_json::from_str(&seal_json).unwrap();
1218        seal_val["pubkey"] = serde_json::Value::String(claimed.public_key().to_hex());
1219        // Re-wrap the forged seal to the recipient under a fresh ephemeral key.
1220        let ephemeral = Keys::generate();
1221        let wrap_ct =
1222            nip44::encrypt(ephemeral.secret_key(), &recipient.public_key(), seal_val.to_string(), nip44::Version::default())
1223                .unwrap();
1224        let forged = EventBuilder::new(Kind::GiftWrap, wrap_ct)
1225            .tags([Tag::public_key(recipient.public_key())])
1226            .finalize(&ephemeral)
1227            .unwrap();
1228        assert!(unwrap_direct_invite(&forged, &recipient).is_err());
1229    }
1230
1231    #[test]
1232    fn direct_invite_rejects_a_non_invite_rumor_kind() {
1233        let inviter = Keys::generate();
1234        let recipient = Keys::generate();
1235        let (_owner, bundle) = valid_bundle();
1236        let json = serde_json::to_string(&bundle).unwrap();
1237
1238        // Hand-build the shape with a kind-9 rumor instead of 3313.
1239        let mut rumor = EventBuilder::new(Kind::Custom(9), json)
1240            .custom_created_at(Timestamp::now())
1241            .finalize_unsigned_with_id(inviter.public_key());
1242        rumor.ensure_id();
1243        let seal_ct =
1244            nip44::encrypt(inviter.secret_key(), &recipient.public_key(), rumor.as_json(), nip44::Version::default()).unwrap();
1245        let seal = EventBuilder::new(Kind::Seal, seal_ct).finalize(&inviter).unwrap();
1246        let ephemeral = Keys::generate();
1247        let wrap_ct =
1248            nip44::encrypt(ephemeral.secret_key(), &recipient.public_key(), seal.as_json(), nip44::Version::default()).unwrap();
1249        let wrap = EventBuilder::new(Kind::GiftWrap, wrap_ct)
1250            .tags([Tag::public_key(recipient.public_key())])
1251            .finalize(&ephemeral)
1252            .unwrap();
1253        assert!(matches!(
1254            unwrap_direct_invite(&wrap, &recipient),
1255            Err(InviteError::BadEvent("rumor is not a direct invite"))
1256        ));
1257    }
1258
1259    #[test]
1260    fn direct_invite_stamps_nip40_expiration_in_seconds() {
1261        let inviter = Keys::generate();
1262        let recipient = Keys::generate();
1263        let (_owner, mut bundle) = valid_bundle();
1264        let expires_ms = 1_735_689_600_000u64;
1265        bundle.expires_at = Some(expires_ms);
1266
1267        let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1268        let exp = wrap.tags.iter().find_map(|t| {
1269            let s = t.as_slice();
1270            (s.len() >= 2 && s[0] == "expiration").then(|| s[1].clone())
1271        });
1272        assert_eq!(exp, Some((expires_ms / 1000).to_string()));
1273    }
1274
1275    #[test]
1276    fn direct_invite_rejects_a_forged_owner_bundle() {
1277        let inviter = Keys::generate();
1278        let recipient = Keys::generate();
1279        let (_owner, mut bundle) = valid_bundle();
1280        bundle.owner = hex(&Keys::generate().public_key().to_bytes()); // forged
1281        let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1282        assert!(matches!(unwrap_direct_invite(&wrap, &recipient), Err(InviteError::OwnerMismatch)));
1283    }
1284
1285    // ── Links ───────────────────────────────────────────────────────────────────
1286
1287    #[test]
1288    fn invite_link_round_trips_full_url_and_bare_form() {
1289        let link = Keys::generate();
1290        let token = token16();
1291        let relays = vec!["wss://a.example".to_string()];
1292        let url = build_invite_url("https://vectorapp.io", &link.public_key(), &token, &relays).unwrap();
1293        assert!(url.contains("/invite/naddr1"));
1294
1295        let parsed = parse_invite_link(&url).unwrap();
1296        assert_eq!(parsed.link_signer, link.public_key());
1297        assert_eq!(parsed.token, token);
1298        assert_eq!(parsed.bootstrap_relays, relays);
1299
1300        let bare = format!("{}#{}", parsed.naddr, url.split('#').nth(1).unwrap());
1301        assert_eq!(parse_invite_link(&bare).unwrap().link_signer, link.public_key());
1302    }
1303
1304    #[test]
1305    fn invite_link_rejects_non_invites() {
1306        assert!(parse_invite_link("hello world").is_err());
1307        assert!(parse_invite_link("wss://relay.example.com").is_err());
1308        assert!(parse_invite_link("https://x/invite/#frag").is_err());
1309    }
1310
1311    #[test]
1312    fn v2_parser_rejects_a_v1_style_url_no_cross_protocol_confusion() {
1313        // Dual-stack dispatch (VectorCore::join_community) tries the v2 parser
1314        // first and falls through to v1 on failure, so the load-bearing invariant
1315        // is that v2 NEVER accepts a v1 link. A v1 URL is `…/invite#<base64url>`
1316        // (no naddr segment in the path), so the v2 parser fails at the naddr
1317        // step. If this ever passes, a v1 invite would be mis-routed to v2.
1318        let v1_url = "https://vectorapp.io/invite#AmR1bW15djFmcmFnbWVudA";
1319        assert!(parse_invite_link(v1_url).is_err(), "v2 must reject a v1-format invite URL");
1320        // A v1 URL with a trailing slash is likewise not a valid v2 naddr.
1321        assert!(parse_invite_link("https://vectorapp.io/invite/#AmR1bW15").is_err());
1322        // Sanity: a real v2 link still parses (bare naddr#fragment form).
1323        let token = [0x07u8; TOKEN_LEN];
1324        let signer = Keys::generate();
1325        let naddr = bundle_naddr(&signer.public_key()).unwrap();
1326        let frag = encode_fragment(&token, &[]).unwrap();
1327        assert!(parse_invite_link(&format!("{naddr}#{frag}")).is_ok());
1328    }
1329
1330    // ── Registry ────────────────────────────────────────────────────────────────
1331
1332    #[test]
1333    fn registry_content_round_trips_and_leaks_no_secrets() {
1334        let a = Keys::generate();
1335        let b = Keys::generate();
1336        let content = build_registry_content(&[a.public_key(), b.public_key()]);
1337
1338        // Coordinates only: exactly the two pubkey hexes, nothing else.
1339        let arr: Vec<String> = serde_json::from_str(&content).unwrap();
1340        assert_eq!(arr, vec![a.public_key().to_hex(), b.public_key().to_hex()]);
1341        // No token / url / secret ever rides the member-facing Registry.
1342        assert!(!content.contains("invite"));
1343        assert!(!content.contains("token"));
1344        assert!(!content.contains("://"));
1345
1346        let back = parse_registry_content(&content).unwrap();
1347        assert_eq!(back, vec![a.public_key(), b.public_key()]);
1348    }
1349}