use nostr_sdk::nips::nip44::{
self,
v2::{decrypt_to_bytes, encrypt_to_bytes, ConversationKey},
};
use nostr_sdk::nips::nip59::RANGE_RANDOM_TIMESTAMP_TWEAK;
use nostr_sdk::prelude::{
Event, EventBuilder, FromBech32, JsonUtil, Keys, Kind, PublicKey, Tag, TagKind, Timestamp, ToBech32, UnsignedEvent,
};
use serde::{Deserialize, Serialize};
use super::super::{cap_relays, CommunityId};
use super::control::ImageRef;
use super::derive::{verify_community_id, TOKEN_LEN};
use super::{kind, vsk};
pub const MAX_BUNDLE_CHANNELS: usize = 256;
pub const MAX_BUNDLE_EPOCH: u64 = 1 << 40;
pub const FRAGMENT_VERSION: u8 = 4;
pub const MAX_BOOTSTRAP_RELAYS: usize = 3;
const FLAG_STOCK_SET: u8 = 0x01;
const RELAY_DICT: [&str; 4] = [
"wss://jskitty.com/nostr", "wss://asia.vectorapp.io/nostr", "wss://relay.ditto.pub", "wss://relay.dreamith.to", ];
#[derive(Debug)]
pub enum InviteError {
Json(String),
BadHex(&'static str),
TooManyChannels(usize),
OwnerMismatch,
BadFragment(&'static str),
BadVersion(u8),
BadLink(&'static str),
BadEvent(&'static str),
Crypto(String),
}
impl std::fmt::Display for InviteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
InviteError::Json(e) => write!(f, "json: {e}"),
InviteError::BadHex(field) => write!(f, "field {field} is not 32-byte hex"),
InviteError::TooManyChannels(n) => write!(f, "bundle carries {n} channels (cap {MAX_BUNDLE_CHANNELS})"),
InviteError::OwnerMismatch => write!(f, "bundle owner does not reproduce its community_id"),
InviteError::BadFragment(why) => write!(f, "bad invite fragment: {why}"),
InviteError::BadVersion(v) => write!(f, "unsupported invite fragment version {v}"),
InviteError::BadLink(why) => write!(f, "bad invite link: {why}"),
InviteError::BadEvent(why) => write!(f, "bad invite bundle event: {why}"),
InviteError::Crypto(e) => write!(f, "crypto: {e}"),
}
}
}
impl std::error::Error for InviteError {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChannelGrant {
pub id: String,
pub key: String,
pub epoch: u64,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommunityInvite {
pub community_id: String,
pub owner: String,
pub owner_salt: String,
pub community_root: String,
pub root_epoch: u64,
pub channels: Vec<ChannelGrant>,
pub relays: Vec<String>,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<ImageRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub creator_npub: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl CommunityInvite {
pub fn from_bundle_json(json: &str) -> Result<Self, InviteError> {
let mut bundle: CommunityInvite = serde_json::from_str(json).map_err(|e| InviteError::Json(e.to_string()))?;
if bundle.channels.len() > MAX_BUNDLE_CHANNELS {
return Err(InviteError::TooManyChannels(bundle.channels.len()));
}
bundle.relays = cap_relays(std::mem::take(&mut bundle.relays));
bundle.validate()?;
Ok(bundle)
}
pub fn validate(&self) -> Result<(), InviteError> {
if self.channels.len() > MAX_BUNDLE_CHANNELS {
return Err(InviteError::TooManyChannels(self.channels.len()));
}
if self.root_epoch > MAX_BUNDLE_EPOCH || self.channels.iter().any(|c| c.epoch > MAX_BUNDLE_EPOCH) {
return Err(InviteError::BadFragment("epoch out of range"));
}
let owner = hex32(&self.owner, "owner")?;
let salt = hex32(&self.owner_salt, "owner_salt")?;
let cid = hex32(&self.community_id, "community_id")?;
if !verify_community_id(&CommunityId(cid), &owner, &salt) {
return Err(InviteError::OwnerMismatch);
}
Ok(())
}
pub fn expired(&self, now_ms: u64) -> bool {
self.expires_at.is_some_and(|e| now_ms > e)
}
}
#[derive(Debug)]
pub enum BundleState {
Live(Box<CommunityInvite>),
Revoked,
}
pub fn build_bundle_event(
link_signer: &Keys,
bundle: &CommunityInvite,
bundle_key: &[u8; 32],
) -> Result<Event, InviteError> {
let json = serde_json::to_string(bundle).map_err(|e| InviteError::Json(e.to_string()))?;
let content = seal_bundle(bundle_key, &json)?;
EventBuilder::new(Kind::Custom(kind::INVITE_BUNDLE), content)
.tags([d_empty(), vsk_tag(vsk::INVITE_LIVE)])
.sign_with_keys(link_signer)
.map_err(|e| InviteError::Crypto(e.to_string()))
}
pub fn build_revocation(link_signer: &Keys) -> Result<Event, InviteError> {
EventBuilder::new(Kind::Custom(kind::INVITE_BUNDLE), "")
.tags([d_empty(), vsk_tag(vsk::INVITE_REVOKED)])
.sign_with_keys(link_signer)
.map_err(|e| InviteError::Crypto(e.to_string()))
}
pub fn parse_bundle_event(
event: &Event,
expected_signer: &PublicKey,
bundle_key: &[u8; 32],
) -> Result<BundleState, InviteError> {
if event.kind.as_u16() != kind::INVITE_BUNDLE {
return Err(InviteError::BadEvent("wrong kind"));
}
if event.pubkey != *expected_signer {
return Err(InviteError::BadEvent("author is not the link signer"));
}
if !first_tag(event, "d").unwrap_or_default().is_empty() {
return Err(InviteError::BadEvent("bundle is not at the link's coordinate"));
}
event.verify().map_err(|_| InviteError::BadEvent("signature invalid"))?;
match first_tag(event, "vsk").as_deref() {
Some(v) if v == vsk::INVITE_REVOKED => return Ok(BundleState::Revoked),
Some(v) if v == vsk::INVITE_LIVE => {}
_ => return Err(InviteError::BadEvent("unknown or missing bundle marker")),
}
let json = open_bundle(bundle_key, &event.content)?;
Ok(BundleState::Live(Box::new(CommunityInvite::from_bundle_json(&json)?)))
}
const INVITE_PATH: &str = "/invite/";
#[derive(Debug, Clone)]
pub struct ParsedInviteLink {
pub link_signer: PublicKey,
pub token: [u8; TOKEN_LEN],
pub bootstrap_relays: Vec<String>,
pub naddr: String,
}
pub fn stock_relays() -> Vec<String> {
RELAY_DICT.iter().map(|s| s.to_string()).collect()
}
pub fn encode_fragment(token: &[u8; TOKEN_LEN], relays: &[String]) -> Result<String, InviteError> {
let is_stock = relays.len() == RELAY_DICT.len() && relays.iter().zip(RELAY_DICT.iter()).all(|(r, d)| r == d);
let mut bytes = Vec::with_capacity(2 + TOKEN_LEN + relays.len() * 8);
bytes.push(FRAGMENT_VERSION);
if is_stock {
bytes.push(FLAG_STOCK_SET);
} else {
bytes.push(0x00);
let bounded = &relays[..relays.len().min(MAX_BOOTSTRAP_RELAYS)];
bytes.push(bounded.len() as u8);
for relay in bounded {
if let Some(id) = dict_id(relay) {
bytes.push(id);
} else if let Some(host) = relay.strip_prefix("wss://") {
if host.len() > u8::MAX as usize {
return Err(InviteError::BadFragment("relay host too long"));
}
bytes.extend_from_slice(&[0x00, host.len() as u8]);
bytes.extend_from_slice(host.as_bytes());
} else {
if relay.len() > u8::MAX as usize {
return Err(InviteError::BadFragment("relay url too long"));
}
bytes.extend_from_slice(&[0xff, relay.len() as u8]);
bytes.extend_from_slice(relay.as_bytes());
}
}
}
bytes.extend_from_slice(token);
Ok(base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes))
}
pub fn decode_fragment(fragment: &str) -> Result<([u8; TOKEN_LEN], Vec<String>), InviteError> {
let bytes = base64_simd::URL_SAFE_NO_PAD
.decode_to_vec(fragment.trim().as_bytes())
.map_err(|_| InviteError::BadFragment("not base64url"))?;
if bytes.len() < 2 {
return Err(InviteError::BadFragment("truncated"));
}
let version = bytes[0];
if version != FRAGMENT_VERSION {
return Err(InviteError::BadVersion(version));
}
let flags = bytes[1];
let mut o = 2usize;
let mut relays = Vec::new();
if flags & FLAG_STOCK_SET != 0 {
relays = stock_relays();
} else {
let count = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))? as usize;
o += 1;
if count > MAX_BOOTSTRAP_RELAYS {
return Err(InviteError::BadFragment("too many bootstrap relays"));
}
for _ in 0..count {
let lead = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))?;
o += 1;
if (1..=254).contains(&lead) {
if let Some(url) = dict_url(lead) {
relays.push(url.to_string());
}
} else {
let len = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))? as usize;
o += 1;
let end = o.checked_add(len).ok_or(InviteError::BadFragment("truncated"))?;
let raw = bytes.get(o..end).ok_or(InviteError::BadFragment("truncated"))?;
let text = std::str::from_utf8(raw).map_err(|_| InviteError::BadFragment("relay not utf8"))?;
relays.push(if lead == 255 { text.to_string() } else { format!("wss://{text}") });
o = end;
}
}
}
let end = o.checked_add(TOKEN_LEN).ok_or(InviteError::BadFragment("truncated"))?;
let raw = bytes.get(o..end).ok_or(InviteError::BadFragment("truncated"))?;
let mut token = [0u8; TOKEN_LEN];
token.copy_from_slice(raw);
if end != bytes.len() {
return Err(InviteError::BadFragment("trailing bytes"));
}
Ok((token, relays))
}
pub fn bundle_naddr(link_signer: &PublicKey) -> Result<String, InviteError> {
let coord = nostr_sdk::nips::nip01::Coordinate {
kind: Kind::Custom(kind::INVITE_BUNDLE),
public_key: *link_signer,
identifier: String::new(),
};
let n19 = nostr_sdk::nips::nip19::Nip19Coordinate { coordinate: coord, relays: Vec::new() };
nostr_sdk::nips::nip19::Nip19::Coordinate(n19)
.to_bech32()
.map_err(|e| InviteError::Crypto(e.to_string()))
}
pub fn build_invite_url(
base: &str,
link_signer: &PublicKey,
token: &[u8; TOKEN_LEN],
relays: &[String],
) -> Result<String, InviteError> {
let naddr = bundle_naddr(link_signer)?;
let fragment = encode_fragment(token, relays)?;
Ok(format!("{}{INVITE_PATH}{naddr}#{fragment}", base.trim_end_matches('/')))
}
pub fn parse_invite_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
let (locator, fragment) = input.trim().split_once('#').ok_or(InviteError::BadLink("no fragment"))?;
if fragment.is_empty() {
return Err(InviteError::BadLink("empty fragment"));
}
let naddr = match locator.find(INVITE_PATH) {
Some(i) => locator[i + INVITE_PATH.len()..].trim_end_matches('/'),
None => locator.trim_start_matches("nostr:"),
};
let link_signer = signer_from_naddr(naddr)?;
let (token, bootstrap_relays) = decode_fragment(fragment)?;
Ok(ParsedInviteLink { link_signer, token, bootstrap_relays, naddr: naddr.to_string() })
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InviteEntry {
pub token: String,
pub signer_sk: String,
pub community_id: String,
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub created_at: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InviteTombstone {
pub token: String,
pub community_id: String,
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct InviteList {
#[serde(default)]
pub entries: Vec<InviteEntry>,
#[serde(default)]
pub tombstones: Vec<InviteTombstone>,
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
pub fn merge_invite_lists(a: InviteList, b: InviteList) -> InviteList {
use std::collections::BTreeMap;
let mut entries: BTreeMap<String, InviteEntry> = BTreeMap::new();
for e in a.entries.into_iter().chain(b.entries) {
entries.entry(e.token.clone()).or_insert(e);
}
let mut tombstones: BTreeMap<String, InviteTombstone> = BTreeMap::new();
for t in a.tombstones.into_iter().chain(b.tombstones) {
tombstones.entry(t.token.clone()).or_insert(t);
}
for token in tombstones.keys() {
entries.remove(token);
}
let mut extra = a.extra;
extra.extend(b.extra);
InviteList {
entries: entries.into_values().collect(),
tombstones: tombstones.into_values().collect(),
extra,
}
}
pub fn build_invite_list_event(my_keys: &Keys, list: &InviteList) -> Result<Event, InviteError> {
use nostr_sdk::nips::nip44::{encrypt, Version};
let json = serde_json::to_string(list).map_err(|e| InviteError::Json(e.to_string()))?;
let content = encrypt(my_keys.secret_key(), &my_keys.public_key(), json.as_bytes(), Version::V2).map_err(|e| InviteError::Crypto(e.to_string()))?;
EventBuilder::new(Kind::Custom(kind::INVITE_LIST), content)
.sign_with_keys(my_keys)
.map_err(|e| InviteError::Crypto(e.to_string()))
}
pub fn parse_invite_list_event(event: &Event, my_keys: &Keys) -> Result<InviteList, InviteError> {
use nostr_sdk::nips::nip44::decrypt;
if event.kind.as_u16() != kind::INVITE_LIST {
return Err(InviteError::BadEvent("not a kind-13303 invite list"));
}
let json = decrypt(my_keys.secret_key(), &my_keys.public_key(), &event.content).map_err(|e| InviteError::Crypto(e.to_string()))?;
serde_json::from_str(&json).map_err(|e| InviteError::Json(e.to_string()))
}
pub fn build_registry_content(signers: &[PublicKey]) -> String {
let hexes: Vec<String> = signers.iter().map(|p| p.to_hex()).collect();
serde_json::to_string(&hexes).expect("Vec<String> always serializes")
}
pub fn parse_registry_content(content: &str) -> Result<Vec<PublicKey>, InviteError> {
let hexes: Vec<String> = serde_json::from_str(content).map_err(|e| InviteError::Json(e.to_string()))?;
hexes
.iter()
.map(|h| PublicKey::from_hex(h).map_err(|_| InviteError::BadHex("registry signer")))
.collect()
}
pub fn build_direct_invite(
inviter_keys: &Keys,
recipient: &PublicKey,
bundle: &CommunityInvite,
) -> Result<Event, InviteError> {
let json = serde_json::to_string(bundle).map_err(|e| InviteError::Json(e.to_string()))?;
let mut rumor = EventBuilder::new(Kind::Custom(kind::DIRECT_INVITE), json)
.custom_created_at(Timestamp::now())
.build(inviter_keys.public_key());
rumor.ensure_id();
let seal_content = nip44::encrypt(inviter_keys.secret_key(), recipient, rumor.as_json(), nip44::Version::default())
.map_err(|e| InviteError::Crypto(e.to_string()))?;
let seal = EventBuilder::new(Kind::Seal, seal_content)
.custom_created_at(Timestamp::tweaked(RANGE_RANDOM_TIMESTAMP_TWEAK))
.sign_with_keys(inviter_keys)
.map_err(|e| InviteError::Crypto(e.to_string()))?;
let ephemeral = Keys::generate();
let wrap_content = nip44::encrypt(ephemeral.secret_key(), recipient, seal.as_json(), nip44::Version::default())
.map_err(|e| InviteError::Crypto(e.to_string()))?;
let mut tags = vec![
Tag::public_key(*recipient),
Tag::custom(TagKind::Custom("k".into()), [kind::DIRECT_INVITE.to_string()]),
];
if let Some(ms) = bundle.expires_at {
tags.push(Tag::custom(TagKind::Custom("expiration".into()), [(ms / 1000).to_string()]));
}
EventBuilder::new(Kind::GiftWrap, wrap_content)
.tags(tags)
.custom_created_at(Timestamp::tweaked(RANGE_RANDOM_TIMESTAMP_TWEAK))
.sign_with_keys(&ephemeral)
.map_err(|e| InviteError::Crypto(e.to_string()))
}
pub fn unwrap_direct_invite(wrap: &Event, recipient_keys: &Keys) -> Result<(PublicKey, CommunityInvite), InviteError> {
if wrap.kind != Kind::GiftWrap {
return Err(InviteError::BadEvent("not a gift wrap"));
}
let seal_json = nip44::decrypt(recipient_keys.secret_key(), &wrap.pubkey, &wrap.content)
.map_err(|e| InviteError::Crypto(e.to_string()))?;
let seal = Event::from_json(&seal_json).map_err(|e| InviteError::Json(e.to_string()))?;
if seal.kind != Kind::Seal {
return Err(InviteError::BadEvent("inner is not a seal"));
}
seal.verify().map_err(|_| InviteError::BadEvent("seal signature invalid"))?;
let rumor_json = nip44::decrypt(recipient_keys.secret_key(), &seal.pubkey, &seal.content)
.map_err(|e| InviteError::Crypto(e.to_string()))?;
let rumor = UnsignedEvent::from_json(rumor_json.as_bytes()).map_err(|e| InviteError::Json(e.to_string()))?;
if rumor.kind.as_u16() != kind::DIRECT_INVITE {
return Err(InviteError::BadEvent("rumor is not a direct invite"));
}
if rumor.pubkey != seal.pubkey {
return Err(InviteError::BadEvent("rumor author does not match the seal signer"));
}
let bundle = CommunityInvite::from_bundle_json(&rumor.content)?;
Ok((seal.pubkey, bundle))
}
fn hex32(s: &str, field: &'static str) -> Result<[u8; 32], InviteError> {
crate::simd::hex::hex_to_bytes_32_checked(s).ok_or(InviteError::BadHex(field))
}
fn dict_id(url: &str) -> Option<u8> {
RELAY_DICT.iter().position(|u| *u == url).map(|i| (i + 1) as u8)
}
fn dict_url(id: u8) -> Option<&'static str> {
RELAY_DICT.get((id as usize).checked_sub(1)?).copied()
}
fn d_empty() -> Tag {
Tag::custom(TagKind::Custom("d".into()), [""])
}
fn vsk_tag(value: &str) -> Tag {
Tag::custom(TagKind::Custom("vsk".into()), [value])
}
fn first_tag(event: &Event, name: &str) -> Option<String> {
event.tags.iter().find_map(|t| {
let s = t.as_slice();
(s.len() >= 2 && s[0] == name).then(|| s[1].clone())
})
}
pub(crate) fn seal_bundle(bundle_key: &[u8; 32], json: &str) -> Result<String, InviteError> {
let ck = ConversationKey::new(*bundle_key);
let ct = encrypt_to_bytes(&ck, json.as_bytes()).map_err(|e| InviteError::Crypto(e.to_string()))?;
Ok(base64_simd::STANDARD.encode_to_string(&ct))
}
fn open_bundle(bundle_key: &[u8; 32], content: &str) -> Result<String, InviteError> {
let ck = ConversationKey::new(*bundle_key);
let ct = base64_simd::STANDARD
.decode_to_vec(content.as_bytes())
.map_err(|e| InviteError::Crypto(e.to_string()))?;
let pt = decrypt_to_bytes(&ck, &ct).map_err(|e| InviteError::Crypto(e.to_string()))?;
String::from_utf8(pt).map_err(|e| InviteError::Crypto(e.to_string()))
}
fn signer_from_naddr(naddr: &str) -> Result<PublicKey, InviteError> {
let trimmed = naddr.trim().trim_start_matches("nostr:");
let n19 = nostr_sdk::nips::nip19::Nip19::from_bech32(trimmed).map_err(|_| InviteError::BadLink("invalid naddr"))?;
match n19 {
nostr_sdk::nips::nip19::Nip19::Coordinate(c)
if c.coordinate.kind.as_u16() == kind::INVITE_BUNDLE && c.coordinate.identifier.is_empty() =>
{
Ok(c.coordinate.public_key)
}
_ => Err(InviteError::BadLink("naddr is not an invite-bundle coordinate")),
}
}
#[cfg(test)]
mod tests {
use super::super::control::CommunityIdentity;
use super::super::derive::invite_bundle_key;
use super::*;
use crate::community::{random_32, MAX_COMMUNITY_RELAYS};
fn hex(bytes: &[u8; 32]) -> String {
crate::simd::hex::bytes_to_hex_32(bytes)
}
fn valid_bundle() -> (Keys, CommunityInvite) {
let owner = Keys::generate();
let id = CommunityIdentity::mint(&owner.public_key());
let bundle = CommunityInvite {
community_id: hex(&id.community_id.0),
owner: hex(&id.owner_xonly),
owner_salt: hex(&id.owner_salt),
community_root: hex(&random_32()),
root_epoch: 0,
channels: vec![],
relays: vec!["wss://a.example".into(), "wss://b.example".into()],
name: "Test community".into(),
icon: None,
expires_at: None,
creator_npub: None,
label: None,
extra: Default::default(),
};
(owner, bundle)
}
fn token16() -> [u8; TOKEN_LEN] {
std::array::from_fn(|i| i as u8) }
#[test]
fn validate_accepts_a_correct_owner_salt_id_triple() {
let (_owner, bundle) = valid_bundle();
assert!(bundle.validate().is_ok());
}
#[test]
fn validate_rejects_a_forged_owner() {
let (_owner, mut bundle) = valid_bundle();
let attacker = Keys::generate();
bundle.owner = hex(&attacker.public_key().to_bytes());
assert!(matches!(bundle.validate(), Err(InviteError::OwnerMismatch)));
}
#[test]
fn from_json_rejects_over_the_channel_cap() {
let (_owner, mut bundle) = valid_bundle();
bundle.channels = (0..MAX_BUNDLE_CHANNELS + 1)
.map(|_| ChannelGrant { id: hex(&random_32()), key: hex(&random_32()), epoch: 0, name: "x".into() })
.collect();
let json = serde_json::to_string(&bundle).unwrap();
assert!(matches!(
CommunityInvite::from_bundle_json(&json),
Err(InviteError::TooManyChannels(n)) if n == MAX_BUNDLE_CHANNELS + 1
));
}
#[test]
fn from_json_truncates_relays_to_the_community_cap() {
let (_owner, mut bundle) = valid_bundle();
bundle.relays = (0..MAX_COMMUNITY_RELAYS + 3).map(|i| format!("wss://r{i}.example")).collect();
let json = serde_json::to_string(&bundle).unwrap();
let parsed = CommunityInvite::from_bundle_json(&json).unwrap();
assert_eq!(parsed.relays.len(), MAX_COMMUNITY_RELAYS);
}
#[test]
fn unknown_bundle_fields_round_trip() {
let (_owner, bundle) = valid_bundle();
let mut value = serde_json::to_value(&bundle).unwrap();
let obj = value.as_object_mut().unwrap();
obj.insert("held_roots".into(), serde_json::json!([{ "epoch": 2, "key": "ab" }]));
obj.insert("refounder".into(), serde_json::json!("deadbeef"));
obj.insert("future_field".into(), serde_json::json!({ "deep": [1, 2] }));
let json = serde_json::to_string(&value).unwrap();
let parsed = CommunityInvite::from_bundle_json(&json).unwrap();
let out: serde_json::Value = serde_json::from_str(&serde_json::to_string(&parsed).unwrap()).unwrap();
assert_eq!(out["held_roots"][0]["epoch"], 2);
assert_eq!(out["refounder"], "deadbeef");
assert_eq!(out["future_field"]["deep"][1], 2);
}
#[test]
fn expiry_is_a_join_gate_not_a_render_gate() {
let (_owner, mut bundle) = valid_bundle();
bundle.expires_at = Some(1_000_000);
assert!(!bundle.expired(999_999));
assert!(bundle.expired(1_000_001));
}
#[test]
fn fragment_golden_stock_set() {
let token = token16();
let frag = encode_fragment(&token, &stock_relays()).unwrap();
assert_eq!(frag, "BAEAAQIDBAUGBwgJCgsMDQ4P");
let (t, relays) = decode_fragment(&frag).unwrap();
assert_eq!(t, token);
assert_eq!(relays, stock_relays());
}
#[test]
fn fragment_golden_dictionary_id_mix() {
let token = token16();
let relays = vec![RELAY_DICT[1].to_string(), RELAY_DICT[3].to_string()];
let frag = encode_fragment(&token, &relays).unwrap();
assert_eq!(frag, "BAACAgQAAQIDBAUGBwgJCgsMDQ4P");
let (t, out) = decode_fragment(&frag).unwrap();
assert_eq!(t, token);
assert_eq!(out, relays);
}
#[test]
fn fragment_golden_wss_implied_literal() {
let token = token16();
let relays = vec!["wss://x.y".to_string()];
let frag = encode_fragment(&token, &relays).unwrap();
assert_eq!(frag, "BAABAAN4LnkAAQIDBAUGBwgJCgsMDQ4P");
let (t, out) = decode_fragment(&frag).unwrap();
assert_eq!(t, token);
assert_eq!(out, relays);
}
#[test]
fn fragment_golden_verbatim_literal() {
let token = token16();
let relays = vec!["ws://h".to_string()];
let frag = encode_fragment(&token, &relays).unwrap();
assert_eq!(frag, "BAAB_wZ3czovL2gAAQIDBAUGBwgJCgsMDQ4P");
let (t, out) = decode_fragment(&frag).unwrap();
assert_eq!(t, token);
assert_eq!(out, relays);
}
#[test]
fn fragment_rejects_wrong_version_both_directions() {
let token = token16();
for bad in [3u8, 5u8] {
let mut bytes = vec![bad, FLAG_STOCK_SET];
bytes.extend_from_slice(&token);
let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
assert!(matches!(decode_fragment(&frag), Err(InviteError::BadVersion(v)) if v == bad));
}
}
#[test]
fn fragment_rejects_trailing_bytes() {
let token = token16();
let mut bytes = vec![FRAGMENT_VERSION, FLAG_STOCK_SET];
bytes.extend_from_slice(&token);
bytes.push(0xff); let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
assert!(matches!(decode_fragment(&frag), Err(InviteError::BadFragment("trailing bytes"))));
}
#[test]
fn fragment_rejects_count_over_three() {
let token = token16();
let mut bytes = vec![FRAGMENT_VERSION, 0x00, 0x04]; bytes.extend_from_slice(&[1, 2, 3, 4]);
bytes.extend_from_slice(&token);
let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
assert!(matches!(decode_fragment(&frag), Err(InviteError::BadFragment("too many bootstrap relays"))));
}
#[test]
fn fragment_skips_an_unknown_dictionary_id() {
let token = token16();
let mut bytes = vec![FRAGMENT_VERSION, 0x00, 0x01, 200];
bytes.extend_from_slice(&token);
let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
let (t, relays) = decode_fragment(&frag).unwrap();
assert_eq!(t, token);
assert!(relays.is_empty());
}
#[test]
fn fragment_caps_bootstrap_relays_at_three() {
let token = token16();
let relays: Vec<String> = (0..4).map(|i| format!("wss://r{i}.example")).collect();
let (_t, out) = decode_fragment(&encode_fragment(&token, &relays).unwrap()).unwrap();
assert_eq!(out.len(), MAX_BOOTSTRAP_RELAYS);
}
#[test]
fn bundle_event_round_trips_live() {
let (_owner, bundle) = valid_bundle();
let link = Keys::generate();
let token = random_32();
let key = invite_bundle_key(&token[..TOKEN_LEN].try_into().unwrap());
let event = build_bundle_event(&link, &bundle, &key).unwrap();
assert_eq!(event.pubkey, link.public_key());
match parse_bundle_event(&event, &link.public_key(), &key).unwrap() {
BundleState::Live(b) => {
assert_eq!(b.community_id, bundle.community_id);
assert_eq!(b.name, "Test community");
}
BundleState::Revoked => panic!("expected Live"),
}
}
#[test]
fn revocation_reads_as_revoked() {
let link = Keys::generate();
let tomb = build_revocation(&link).unwrap();
let key = invite_bundle_key(&[0u8; TOKEN_LEN]);
assert!(matches!(parse_bundle_event(&tomb, &link.public_key(), &key), Ok(BundleState::Revoked)));
}
#[test]
fn bundle_event_wrong_key_fails() {
let (_owner, bundle) = valid_bundle();
let link = Keys::generate();
let key = invite_bundle_key(&[7u8; TOKEN_LEN]);
let event = build_bundle_event(&link, &bundle, &key).unwrap();
let wrong = invite_bundle_key(&[8u8; TOKEN_LEN]);
assert!(parse_bundle_event(&event, &link.public_key(), &wrong).is_err());
}
#[test]
fn bundle_event_from_a_different_author_is_rejected() {
let (_owner, bundle) = valid_bundle();
let real = Keys::generate();
let squatter = Keys::generate();
let key = invite_bundle_key(&[3u8; TOKEN_LEN]);
let event = build_bundle_event(&squatter, &bundle, &key).unwrap();
assert!(matches!(
parse_bundle_event(&event, &real.public_key(), &key),
Err(InviteError::BadEvent("author is not the link signer"))
));
}
#[test]
fn bundle_event_at_a_non_empty_d_is_rejected() {
let (_owner, bundle) = valid_bundle();
let link = Keys::generate();
let key = invite_bundle_key(&[9u8; TOKEN_LEN]);
let json = serde_json::to_string(&bundle).unwrap();
let content = seal_bundle(&key, &json).unwrap();
let event = EventBuilder::new(Kind::Custom(kind::INVITE_BUNDLE), content)
.tags([Tag::identifier("elsewhere"), vsk_tag(vsk::INVITE_LIVE)])
.sign_with_keys(&link)
.unwrap();
assert!(matches!(
parse_bundle_event(&event, &link.public_key(), &key),
Err(InviteError::BadEvent("bundle is not at the link's coordinate"))
));
}
#[test]
fn bundle_event_with_a_tampered_signature_is_rejected() {
let (_owner, bundle) = valid_bundle();
let link = Keys::generate();
let other = Keys::generate();
let key = invite_bundle_key(&[4u8; TOKEN_LEN]);
let event = build_bundle_event(&link, &bundle, &key).unwrap();
let mut json: serde_json::Value = serde_json::from_str(&event.as_json()).unwrap();
json["pubkey"] = serde_json::Value::String(other.public_key().to_hex());
let Ok(forged) = Event::from_json(json.to_string()) else { return };
assert!(matches!(
parse_bundle_event(&forged, &other.public_key(), &key),
Err(InviteError::BadEvent("signature invalid"))
));
}
fn entry(token: &str, community: &str) -> InviteEntry {
InviteEntry {
token: token.into(),
signer_sk: "bb".repeat(32),
community_id: community.into(),
url: "https://x/invite/naddr1xyz#frag".into(),
label: None,
created_at: 1000,
expires_at: None,
extra: Default::default(),
}
}
#[test]
fn invite_list_merge_entries_immutable_tombstone_wins_terminally() {
let e = entry(&"aa".repeat(16), &"cc".repeat(32));
let a = merge_invite_lists(InviteList::default(), InviteList { entries: vec![e.clone()], ..Default::default() });
assert_eq!(a.entries.len(), 1);
let mut mutated = e.clone();
mutated.label = Some("changed".into());
let still = merge_invite_lists(a.clone(), InviteList { entries: vec![mutated], ..Default::default() });
assert_eq!(still.entries[0].label, None);
let tomb = InviteTombstone { token: e.token.clone(), community_id: e.community_id.clone(), extra: Default::default() };
let b = merge_invite_lists(a, InviteList { tombstones: vec![tomb], ..Default::default() });
assert!(b.entries.is_empty());
assert_eq!(b.tombstones.len(), 1);
let c = merge_invite_lists(b, InviteList { entries: vec![e], ..Default::default() });
assert!(c.entries.is_empty());
}
#[test]
fn invite_list_tombstones_union() {
let t1 = InviteTombstone { token: "11".repeat(16), community_id: "aa".repeat(32), extra: Default::default() };
let t2 = InviteTombstone { token: "22".repeat(16), community_id: "bb".repeat(32), extra: Default::default() };
let merged = merge_invite_lists(
InviteList { tombstones: vec![t1], ..Default::default() },
InviteList { tombstones: vec![t2], ..Default::default() },
);
assert_eq!(merged.tombstones.len(), 2);
}
#[test]
fn invite_list_unknown_fields_round_trip() {
let wire = r#"{"entries":[],"tombstones":[],"schema":"future","note":{"x":1}}"#;
let list: InviteList = serde_json::from_str(wire).unwrap();
let out: serde_json::Value = serde_json::from_str(&serde_json::to_string(&list).unwrap()).unwrap();
assert_eq!(out["schema"], "future");
assert_eq!(out["note"]["x"], 1);
}
#[test]
fn direct_invite_round_trips_to_inviter_and_bundle() {
let inviter = Keys::generate();
let recipient = Keys::generate();
let (_owner, bundle) = valid_bundle();
let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
assert_eq!(wrap.kind, Kind::GiftWrap);
assert_ne!(wrap.pubkey, inviter.public_key(), "wrap author is ephemeral");
assert!(wrap.tags.iter().any(|t| {
let s = t.as_slice();
s.len() >= 2 && s[0] == "k" && s[1] == kind::DIRECT_INVITE.to_string()
}));
let (sender, out) = unwrap_direct_invite(&wrap, &recipient).unwrap();
assert_eq!(sender, inviter.public_key());
assert_eq!(out.community_id, bundle.community_id);
assert_eq!(out.name, "Test community");
}
#[test]
fn direct_invite_rejects_a_seal_claiming_a_false_inviter() {
let inviter = Keys::generate();
let claimed = Keys::generate();
let recipient = Keys::generate();
let (_owner, bundle) = valid_bundle();
let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
let seal_json = nip44::decrypt(recipient.secret_key(), &wrap.pubkey, &wrap.content).unwrap();
let mut seal_val: serde_json::Value = serde_json::from_str(&seal_json).unwrap();
seal_val["pubkey"] = serde_json::Value::String(claimed.public_key().to_hex());
let ephemeral = Keys::generate();
let wrap_ct =
nip44::encrypt(ephemeral.secret_key(), &recipient.public_key(), seal_val.to_string(), nip44::Version::default())
.unwrap();
let forged = EventBuilder::new(Kind::GiftWrap, wrap_ct)
.tags([Tag::public_key(recipient.public_key())])
.sign_with_keys(&ephemeral)
.unwrap();
assert!(unwrap_direct_invite(&forged, &recipient).is_err());
}
#[test]
fn direct_invite_rejects_a_non_invite_rumor_kind() {
let inviter = Keys::generate();
let recipient = Keys::generate();
let (_owner, bundle) = valid_bundle();
let json = serde_json::to_string(&bundle).unwrap();
let mut rumor = EventBuilder::new(Kind::Custom(9), json)
.custom_created_at(Timestamp::now())
.build(inviter.public_key());
rumor.ensure_id();
let seal_ct =
nip44::encrypt(inviter.secret_key(), &recipient.public_key(), rumor.as_json(), nip44::Version::default()).unwrap();
let seal = EventBuilder::new(Kind::Seal, seal_ct).sign_with_keys(&inviter).unwrap();
let ephemeral = Keys::generate();
let wrap_ct =
nip44::encrypt(ephemeral.secret_key(), &recipient.public_key(), seal.as_json(), nip44::Version::default()).unwrap();
let wrap = EventBuilder::new(Kind::GiftWrap, wrap_ct)
.tags([Tag::public_key(recipient.public_key())])
.sign_with_keys(&ephemeral)
.unwrap();
assert!(matches!(
unwrap_direct_invite(&wrap, &recipient),
Err(InviteError::BadEvent("rumor is not a direct invite"))
));
}
#[test]
fn direct_invite_stamps_nip40_expiration_in_seconds() {
let inviter = Keys::generate();
let recipient = Keys::generate();
let (_owner, mut bundle) = valid_bundle();
let expires_ms = 1_735_689_600_000u64;
bundle.expires_at = Some(expires_ms);
let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
let exp = wrap.tags.iter().find_map(|t| {
let s = t.as_slice();
(s.len() >= 2 && s[0] == "expiration").then(|| s[1].clone())
});
assert_eq!(exp, Some((expires_ms / 1000).to_string()));
}
#[test]
fn direct_invite_rejects_a_forged_owner_bundle() {
let inviter = Keys::generate();
let recipient = Keys::generate();
let (_owner, mut bundle) = valid_bundle();
bundle.owner = hex(&Keys::generate().public_key().to_bytes()); let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
assert!(matches!(unwrap_direct_invite(&wrap, &recipient), Err(InviteError::OwnerMismatch)));
}
#[test]
fn invite_link_round_trips_full_url_and_bare_form() {
let link = Keys::generate();
let token = token16();
let relays = vec!["wss://a.example".to_string()];
let url = build_invite_url("https://vectorapp.io", &link.public_key(), &token, &relays).unwrap();
assert!(url.contains("/invite/naddr1"));
let parsed = parse_invite_link(&url).unwrap();
assert_eq!(parsed.link_signer, link.public_key());
assert_eq!(parsed.token, token);
assert_eq!(parsed.bootstrap_relays, relays);
let bare = format!("{}#{}", parsed.naddr, url.split('#').nth(1).unwrap());
assert_eq!(parse_invite_link(&bare).unwrap().link_signer, link.public_key());
}
#[test]
fn invite_link_rejects_non_invites() {
assert!(parse_invite_link("hello world").is_err());
assert!(parse_invite_link("wss://relay.example.com").is_err());
assert!(parse_invite_link("https://x/invite/#frag").is_err());
}
#[test]
fn v2_parser_rejects_a_v1_style_url_no_cross_protocol_confusion() {
let v1_url = "https://vectorapp.io/invite#AmR1bW15djFmcmFnbWVudA";
assert!(parse_invite_link(v1_url).is_err(), "v2 must reject a v1-format invite URL");
assert!(parse_invite_link("https://vectorapp.io/invite/#AmR1bW15").is_err());
let token = [0x07u8; TOKEN_LEN];
let signer = Keys::generate();
let naddr = bundle_naddr(&signer.public_key()).unwrap();
let frag = encode_fragment(&token, &[]).unwrap();
assert!(parse_invite_link(&format!("{naddr}#{frag}")).is_ok());
}
#[test]
fn registry_content_round_trips_and_leaks_no_secrets() {
let a = Keys::generate();
let b = Keys::generate();
let content = build_registry_content(&[a.public_key(), b.public_key()]);
let arr: Vec<String> = serde_json::from_str(&content).unwrap();
assert_eq!(arr, vec![a.public_key().to_hex(), b.public_key().to_hex()]);
assert!(!content.contains("invite"));
assert!(!content.contains("token"));
assert!(!content.contains("://"));
let back = parse_registry_content(&content).unwrap();
assert_eq!(back, vec![a.public_key(), b.public_key()]);
}
}