Skip to main content

vector_core/
webxdc.rs

1//! WebXDC Mini App helpers shared across transports (DM + Community).
2//!
3//! The realtime-channel topic for a Mini App is minted ONCE at send time and
4//! carried on the file event as a `webxdc-topic` tag, so every participant
5//! joins the SAME gossip topic. Locally-derived topics are asymmetric in DMs
6//! (each side's chat_id is the other party's npub), which silently splits the
7//! players onto disjoint topics — the tag is the single source of truth.
8
9/// Mint a fresh realtime-channel topic id for an outbound `.xdc` attachment.
10///
11/// 32 bytes of SHA-256 over a domain separator + file hash + sender + send-time
12/// nanos + a per-process counter, encoded base32 (RFC 4648, no padding) — the
13/// same codec the miniapp realtime layer's `decode_topic_id` expects, so the tag
14/// value round-trips into an iroh `TopicId`.
15///
16/// The counter is what actually guarantees re-sends are distinct sessions. The
17/// clock cannot: `SystemTime::now()` reports nanos but does not RESOLVE them, so
18/// two sends of the same file inside one tick hashed identical input and minted
19/// the same topic — the "fresh" session silently rejoined the previous one.
20pub fn mint_topic_id(file_hash: &str, sender_hex: &str) -> String {
21    use sha2::{Digest, Sha256};
22    use std::sync::atomic::{AtomicU64, Ordering};
23    static MINTED: AtomicU64 = AtomicU64::new(0);
24    let nanos = web_time::SystemTime::now()
25        .duration_since(web_time::UNIX_EPOCH)
26        .map(|d| d.as_nanos())
27        .unwrap_or(0);
28    let seq = MINTED.fetch_add(1, Ordering::Relaxed);
29    let mut hasher = Sha256::new();
30    hasher.update(b"webxdc-realtime-v1:");
31    hasher.update(file_hash.as_bytes());
32    hasher.update(b":");
33    hasher.update(sender_hex.as_bytes());
34    hasher.update(b":");
35    hasher.update(nanos.to_le_bytes());
36    hasher.update(b":");
37    hasher.update(seq.to_le_bytes());
38    base32_nopad_encode(&hasher.finalize())
39}
40
41/// Derive the realtime topic for a URL-shared Mini App.
42///
43/// A pasted `.xdc` URL has no file event to carry a minted topic, so every
44/// recipient derives the same one from what the message already gives them:
45/// the URL string and the message id (which keeps re-shares distinct
46/// sessions, the role nanos+counter play in `mint_topic_id`). Deliberately
47/// NOT the content hash: servers rebuild identical apps into new bytes, and
48/// players who tapped the same card at different times must still share a
49/// session — the message is the session anchor, the URL only the source.
50pub fn derive_url_topic_id(url: &str, msg_id: &str) -> String {
51    use sha2::{Digest, Sha256};
52    let mut hasher = Sha256::new();
53    hasher.update(b"webxdc-url-realtime-v1:");
54    hasher.update(url.as_bytes());
55    hasher.update(b":");
56    hasher.update(msg_id.as_bytes());
57    base32_nopad_encode(&hasher.finalize())
58}
59
60/// BASE32 no-pad decoding (RFC 4648), the inverse of [`base32_nopad_encode`].
61pub fn base32_nopad_decode(encoded: &[u8]) -> Result<Vec<u8>, String> {
62    let mut out = Vec::with_capacity(encoded.len() * 5 / 8);
63    let (mut buf, mut bits) = (0u64, 0u32);
64    for &c in encoded {
65        let v = match c {
66            b'A'..=b'Z' => c - b'A',
67            b'a'..=b'z' => c - b'a',
68            b'2'..=b'7' => c - b'2' + 26,
69            _ => return Err(format!("invalid base32 character {:?}", c as char)),
70        };
71        buf = (buf << 5) | v as u64;
72        bits += 5;
73        if bits >= 8 {
74            bits -= 8;
75            out.push((buf >> bits) as u8);
76        }
77    }
78    Ok(out)
79}
80
81/// BASE32 no-pad encoding (RFC 4648). Mirrors the miniapp realtime layer's
82/// codec exactly — the two must agree for topic tags to decode.
83pub fn base32_nopad_encode(bytes: &[u8]) -> String {
84    const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
85    let mut out = String::with_capacity((bytes.len() * 8).div_ceil(5));
86    let mut buf: u64 = 0;
87    let mut bits: u32 = 0;
88    for &b in bytes {
89        buf = (buf << 8) | b as u64;
90        bits += 8;
91        while bits >= 5 {
92            bits -= 5;
93            out.push(ALPHABET[((buf >> bits) & 0x1F) as usize] as char);
94        }
95    }
96    if bits > 0 {
97        out.push(ALPHABET[((buf << (5 - bits)) & 0x1F) as usize] as char);
98    }
99    out
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn minted_topic_is_32_bytes_base32() {
108        let t = mint_topic_id("abc123", "deadbeef");
109        // 32 bytes → ceil(256/5) = 52 base32 chars
110        assert_eq!(t.len(), 52);
111        assert!(t.chars().all(|c| c.is_ascii_uppercase() || ('2'..='7').contains(&c)));
112    }
113
114    #[test]
115    fn resends_mint_distinct_topics() {
116        let a = mint_topic_id("abc123", "deadbeef");
117        let b = mint_topic_id("abc123", "deadbeef");
118        assert_ne!(a, b, "same file re-sent must start a fresh session topic");
119    }
120
121    #[test]
122    fn base32_matches_rfc4648_vectors() {
123        // RFC 4648 §10 test vectors (padding stripped)
124        assert_eq!(base32_nopad_encode(b""), "");
125        assert_eq!(base32_nopad_encode(b"f"), "MY");
126        assert_eq!(base32_nopad_encode(b"fo"), "MZXQ");
127        assert_eq!(base32_nopad_encode(b"foo"), "MZXW6");
128        assert_eq!(base32_nopad_encode(b"foob"), "MZXW6YQ");
129        assert_eq!(base32_nopad_encode(b"fooba"), "MZXW6YTB");
130        assert_eq!(base32_nopad_encode(b"foobar"), "MZXW6YTBOI");
131    }
132}
133
134/// The kind-3310 peer-signal content, shared by every community transport (the
135/// v1 channel plane and the v2 chat plane must stay byte-compatible):
136/// `{"op":"ad","topic":..,"addr":..}` advertises an Iroh node, `{"op":"left",..}`
137/// departs.
138pub fn peer_signal_content(topic_id: &str, node_addr: Option<&str>) -> String {
139    match node_addr {
140        Some(addr) => serde_json::json!({ "op": "ad", "topic": topic_id, "addr": addr }).to_string(),
141        None => serde_json::json!({ "op": "left", "topic": topic_id }).to_string(),
142    }
143}
144
145/// Parse + bound a kind-3310 peer signal: `Some((topic, Some(addr)))` for an
146/// advertisement, `Some((topic, None))` for a departure. Both fields are
147/// author-controlled: the topic must be a 52-char base32 TopicId and the addr is
148/// size-bounded — the realtime layer's decode is the final word.
149pub fn parse_peer_signal(content: &str) -> Option<(String, Option<String>)> {
150    let v: serde_json::Value = serde_json::from_str(content).ok()?;
151    let topic_id = v
152        .get("topic")
153        .and_then(|t| t.as_str())
154        .filter(|t| t.len() == 52 && t.bytes().all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b)))?
155        .to_string();
156    let node_addr = match v.get("op").and_then(|o| o.as_str())? {
157        "ad" => Some(v.get("addr").and_then(|a| a.as_str()).filter(|a| !a.is_empty() && a.len() <= 2048)?.to_string()),
158        "left" => None,
159        _ => return None,
160    };
161    Some((topic_id, node_addr))
162}
163
164#[cfg(test)]
165mod url_topic_tests {
166    use super::*;
167
168    #[test]
169    fn a_url_topic_is_deterministic_per_message_and_survives_server_rebuilds() {
170        let a = derive_url_topic_id("https://x.org/app.xdc", "msg1");
171        assert_eq!(a, derive_url_topic_id("https://x.org/app.xdc", "msg1"));
172        assert_eq!(a.len(), 52, "must be a valid 52-char base32 TopicId");
173        assert!(parse_peer_signal(&peer_signal_content(&a, Some("iroh:x"))).is_some());
174        assert_ne!(a, derive_url_topic_id("https://x.org/other.xdc", "msg1"), "different URL = disjoint topics");
175        assert_ne!(a, derive_url_topic_id("https://x.org/app.xdc", "msg2"), "re-share = fresh session");
176    }
177}
178
179#[cfg(test)]
180mod peer_signal_tests {
181    use super::*;
182
183    #[test]
184    fn peer_signal_round_trips_and_bounds() {
185        let topic = "A".repeat(52);
186        let ad = peer_signal_content(&topic, Some("iroh:node/abc"));
187        assert_eq!(parse_peer_signal(&ad), Some((topic.clone(), Some("iroh:node/abc".into()))));
188        let left = peer_signal_content(&topic, None);
189        assert_eq!(parse_peer_signal(&left), Some((topic.clone(), None)));
190
191        // Author-controlled fields are bounded: bad topic, oversized addr, junk op.
192        assert_eq!(parse_peer_signal(&peer_signal_content("short", Some("a"))), None);
193        let oversized = "a".repeat(2049);
194        assert_eq!(parse_peer_signal(&peer_signal_content(&topic, Some(&oversized))), None);
195        assert_eq!(parse_peer_signal(&format!("{{\"op\":\"warp\",\"topic\":\"{topic}\"}}")), None);
196        assert_eq!(parse_peer_signal("not json"), None);
197    }
198}
199
200/// Represents a Mini App listing in the marketplace
201#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
202#[serde(default)]
203pub struct MarketplaceApp {
204    /// Unique identifier (from d-tag)
205    pub id: String,
206    /// Display name
207    pub name: String,
208    /// Description
209    pub description: String,
210    /// Version string
211    pub version: String,
212    /// Blossom SHA-256 hash of the .xdc file
213    pub blossom_hash: String,
214    /// Full Blossom download URL
215    pub download_url: String,
216    /// File size in bytes
217    pub size: u64,
218    /// Optional icon URL (Blossom)
219    pub icon_url: Option<String>,
220    /// Optional icon MIME type (e.g., "image/png", "image/svg+xml")
221    pub icon_mime: Option<String>,
222    /// Locally cached icon path (for offline support)
223    pub icon_cached: Option<String>,
224    /// Categories/tags
225    pub categories: Vec<String>,
226    /// Extended description or changelog (from content)
227    pub changelog: Option<String>,
228    /// Developer name (the actual creator of the game/app)
229    pub developer: Option<String>,
230    /// Source code or website URL
231    pub source_url: Option<String>,
232    /// Publisher's public key (npub)
233    pub publisher: String,
234    /// Event creation timestamp
235    pub published_at: u64,
236    /// Whether this app is installed locally
237    pub installed: bool,
238    /// Local file path if installed
239    pub local_path: Option<String>,
240    /// Installed version (if different from marketplace version, update is available)
241    pub installed_version: Option<String>,
242    /// Whether an update is available (marketplace version != installed version)
243    pub update_available: bool,
244    /// Requested permissions (comma-separated string for easy serialization)
245    /// Format: "microphone,camera,fullscreen"
246    pub requested_permissions: String,
247}
248
249// ─── Marketplace listings (kind 30078, `t=miniapp`) ─────────────────────────
250
251use nostr_sdk::prelude::*;
252
253/// The event kind for Mini App marketplace listings
254/// Kind 30078 = Parameterized Replaceable Application-Specific Data
255pub const MINIAPP_MARKETPLACE_KIND: u16 = 30078;
256
257/// The application identifier for Vector Mini Apps marketplace
258pub const MINIAPP_APP_ID: &str = "vector/miniapp";
259
260/// Trusted publisher npub (only apps from this pubkey are shown initially)
261/// This is the Vector project's official npub for publishing verified apps
262pub const TRUSTED_PUBLISHER: &str = "npub16ye7evyevwnl0fc9hujsxf9zym72e063awn0pvde0huvpyec5nyq4dg4wn";
263
264/// Parse a Nostr event into a MarketplaceApp
265/// A marketplace `d`-tag id is attacker-controlled and becomes a filesystem path (`<id>.xdc`).
266/// Allow only a bounded, separator-free, traversal-free token so an id can never escape the
267/// miniapps dir. Permits reverse-DNS / slug / hash ids; bans `/`, `\`, `..`, absolute/UNC paths.
268pub fn is_safe_app_id(id: &str) -> bool {
269    !id.is_empty()
270        && id.len() <= 128
271        && !id.contains("..")
272        && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
273}
274
275pub fn parse_marketplace_event(event: &Event) -> Option<MarketplaceApp> {
276    // Verify it's the correct kind
277    if event.kind.as_u16() != MINIAPP_MARKETPLACE_KIND {
278        return None;
279    }
280
281    // Extract required tags
282    let mut id = None;
283    let mut name = None;
284    let mut description = None;
285    let mut version = None;
286    let mut blossom_hash = None;
287    let mut download_url = None;
288    let mut size: Option<u64> = None;
289    let mut icon_url = None;
290    let mut icon_mime = None;
291    let mut categories = Vec::new();
292    let mut developer = None;
293    let mut source_url = None;
294    let mut requested_permissions = String::new();
295
296    for tag in event.tags.iter() {
297        let tag_vec: Vec<String> = tag.clone().to_vec();
298        if tag_vec.len() < 2 {
299            continue;
300        }
301
302        match tag_vec[0].as_str() {
303            "d" => id = Some(tag_vec[1].clone()),
304            "name" => name = Some(tag_vec[1].clone()),
305            "description" => description = Some(tag_vec[1].clone()),
306            "version" => version = Some(tag_vec[1].clone()),
307            "x" => blossom_hash = Some(tag_vec[1].clone()),
308            "url" => download_url = Some(tag_vec[1].clone()),
309            "size" => size = tag_vec[1].parse().ok(),
310            "icon" => {
311                // Icon tag format: ["icon", "<url>", "<mime-type>"]
312                icon_url = Some(tag_vec[1].clone());
313                if tag_vec.len() >= 3 {
314                    icon_mime = Some(tag_vec[2].clone());
315                }
316            }
317            "dev" => developer = Some(tag_vec[1].clone()),
318            "src" => source_url = Some(tag_vec[1].clone()),
319            "permissions" => {
320                // Permissions tag format: ["permissions", "microphone,camera,fullscreen"]
321                requested_permissions = tag_vec[1].clone();
322            }
323            "t" => {
324                // Skip the generic "miniapp" and "webxdc" tags for categories
325                let tag_value = &tag_vec[1];
326                if tag_value != "miniapp" && tag_value != "webxdc" {
327                    categories.push(tag_value.clone());
328                }
329            }
330            _ => {}
331        }
332    }
333
334    // Validate required fields
335    let id = id?;
336    if !is_safe_app_id(&id) {
337        log_warn!("[Marketplace] Rejected app with unsafe d-tag id: {:?}", id);
338        return None;
339    }
340    let name = name?;
341    let blossom_hash = blossom_hash?;
342    let download_url = download_url?;
343
344    let publisher = event.pubkey.to_bech32().unwrap_or_else(|_| event.pubkey.to_hex());
345
346    Some(MarketplaceApp {
347        id,
348        name,
349        description: description.unwrap_or_default(),
350        version: version.unwrap_or_else(|| "1.0.0".to_string()),
351        blossom_hash,
352        download_url,
353        size: size.unwrap_or(0),
354        icon_url,
355        icon_mime,
356        icon_cached: None,
357        categories,
358        changelog: if event.content.is_empty() { None } else { Some(event.content.clone()) },
359        developer,
360        source_url,
361        publisher,
362        published_at: event.created_at.as_secs(),
363        installed: false,
364        local_path: None,
365        installed_version: None,
366        update_available: false,
367        requested_permissions,
368    })
369}
370
371/// The publisher-supplied metadata of a marketplace listing.
372pub struct MarketplaceListing<'a> {
373    pub app_id: &'a str,
374    pub name: &'a str,
375    pub description: &'a str,
376    pub version: &'a str,
377    pub categories: Vec<&'a str>,
378    pub changelog: Option<&'a str>,
379    pub developer: Option<&'a str>,
380    pub source_url: Option<&'a str>,
381    /// Comma-separated permissions string
382    pub permissions: Option<&'a str>,
383}
384
385/// Build a Nostr event for publishing a Mini App to the marketplace
386pub async fn build_marketplace_event<T: crate::signer::VectorSigner>(
387    signer: &T,
388    listing: &MarketplaceListing<'_>,
389    blossom_hash: &str,
390    download_url: &str,
391    size: u64,
392    icon_info: Option<(&str, &str)>, // (url, mime_type)
393) -> Result<Event, String> {
394    let MarketplaceListing {
395        app_id,
396        name,
397        description,
398        version,
399        ref categories,
400        changelog,
401        developer,
402        source_url,
403        permissions,
404    } = *listing;
405    let mut tags = vec![
406        Tag::custom("d", vec![app_id.to_string()]),
407        Tag::custom("name", vec![name.to_string()]),
408        Tag::custom("description", vec![description.to_string()]),
409        Tag::custom("version", vec![version.to_string()]),
410        Tag::custom("x", vec![blossom_hash.to_string()]),
411        Tag::custom("url", vec![download_url.to_string()]),
412        Tag::custom("size", vec![size.to_string()]),
413        Tag::custom("t", vec!["miniapp".to_string()]),
414        Tag::custom("t", vec!["webxdc".to_string()]),
415    ];
416
417    // Add optional icon with MIME type: ["icon", "<url>", "<mime-type>"]
418    if let Some((icon_url, mime_type)) = icon_info {
419        tags.push(Tag::custom(
420            "icon",
421            vec![icon_url.to_string(), mime_type.to_string()]
422        ));
423    }
424
425    // Add optional developer name
426    if let Some(dev) = developer {
427        if !dev.is_empty() {
428            tags.push(Tag::custom(
429                "dev",
430                vec![dev.to_string()]
431            ));
432        }
433    }
434
435    // Add optional source URL
436    if let Some(src) = source_url {
437        if !src.is_empty() {
438            tags.push(Tag::custom(
439                "src",
440                vec![src.to_string()]
441            ));
442        }
443    }
444
445    // Add category tags
446    for category in categories {
447        tags.push(Tag::custom("t", vec![category.to_string()]));
448    }
449
450    // Add optional permissions tag
451    if let Some(perms) = permissions {
452        if !perms.is_empty() {
453            tags.push(Tag::custom(
454                "permissions",
455                vec![perms.to_string()]
456            ));
457        }
458    }
459
460    let content = changelog.unwrap_or("");
461
462    let event_builder = EventBuilder::new(Kind::from(MINIAPP_MARKETPLACE_KIND), content)
463        .tags(tags);
464
465    event_builder
466        .finalize_async(signer)
467        .await
468        .map_err(|e| format!("Failed to sign marketplace event: {}", e))
469}
470
471#[cfg(test)]
472mod app_id_tests {
473    use super::is_safe_app_id;
474
475    #[test]
476    fn accepts_normal_ids() {
477        for id in ["com.example.app", "my-cool-app", "abc123", "app.v2", "a"] {
478            assert!(is_safe_app_id(id), "should accept {:?}", id);
479        }
480        assert!(is_safe_app_id(&"x".repeat(128)), "128 chars should be accepted");
481    }
482
483    #[test]
484    fn rejects_traversal_and_unsafe() {
485        for id in ["", "..", "....", "a..b", "../../etc/passwd", "..\\win",
486                   "foo/bar", "foo\\bar", "/abs", "C:\\x", "app id", "café"] {
487            assert!(!is_safe_app_id(id), "should reject {:?}", id);
488        }
489        assert!(!is_safe_app_id(&"x".repeat(129)), "129 chars should be rejected");
490    }
491}