//! Decentralized app marketplace: discover, verify, and publish app manifests //! via Nostr relays. Uses NIP-78 (kind 30078) with d-tag "archipelago-app:". //! //! See docs/marketplace-protocol.md for the full protocol specification. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; use std::time::Duration; use tokio::fs; use tracing::{debug, info, warn}; const MARKETPLACE_DIR: &str = "marketplace"; const CACHE_FILE: &str = "manifests.json"; const PUBLISHED_DIR: &str = "published"; const ARCHIPELAGO_KIND: u64 = 30078; const D_TAG_PREFIX: &str = "archipelago-app:"; const MARKETPLACE_TAG: &str = "archipelago-marketplace"; /// Categories for marketplace apps. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum AppCategory { Money, Commerce, Data, Networking, Home, Community, Other, } impl std::fmt::Display for AppCategory { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Money => write!(f, "money"), Self::Commerce => write!(f, "commerce"), Self::Data => write!(f, "data"), Self::Networking => write!(f, "networking"), Self::Home => write!(f, "home"), Self::Community => write!(f, "community"), Self::Other => write!(f, "other"), } } } /// Author information in a marketplace manifest. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManifestAuthor { pub name: String, pub did: String, #[serde(default)] pub nostr_pubkey: String, } /// Container configuration in a marketplace manifest. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManifestContainer { pub image: String, #[serde(default)] pub ports: Vec, #[serde(default)] pub volumes: Vec, #[serde(default)] pub env: HashMap, #[serde(default)] pub capabilities: Vec, #[serde(default = "default_true")] pub readonly_root: bool, #[serde(default = "default_true")] pub no_new_privileges: bool, #[serde(default = "default_uid")] pub run_as_user: u32, } fn default_true() -> bool { true } fn default_uid() -> u32 { 1000 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PortMapping { pub container: u16, pub host: u16, #[serde(default = "default_tcp")] pub protocol: String, } fn default_tcp() -> String { "tcp".into() } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VolumeMapping { pub name: String, pub path: String, } /// App manifest signatures. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManifestSignatures { pub manifest_hash: String, pub did_signature: String, } /// A marketplace app manifest. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppManifest { pub app_id: String, pub name: String, pub version: String, pub description: ManifestDescription, pub author: ManifestAuthor, pub container: ManifestContainer, pub category: AppCategory, #[serde(default)] pub icon_url: String, #[serde(default)] pub repo_url: String, #[serde(default)] pub license: String, #[serde(default)] pub min_archipelago_version: String, #[serde(default)] pub dependencies: Vec, #[serde(default)] pub signatures: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum ManifestDescription { Simple(String), Detailed { short: String, long: String }, } impl ManifestDescription { /// Return the short description regardless of variant. #[allow(dead_code)] pub fn short(&self) -> &str { match self { Self::Simple(s) => s, Self::Detailed { short, .. } => short, } } } /// A discovered marketplace app with trust scoring. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiscoveredApp { pub manifest: AppManifest, pub trust_score: u32, pub trust_tier: String, pub relay_count: u32, pub first_seen: String, pub nostr_pubkey: String, } /// Cache of discovered marketplace apps. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct MarketplaceCache { pub apps: Vec, pub last_updated: String, } /// Ensure marketplace directories exist. async fn ensure_dirs(data_dir: &Path) -> Result<()> { let market_dir = data_dir.join(MARKETPLACE_DIR); fs::create_dir_all(market_dir.join("cache")) .await .context("Creating marketplace cache dir")?; fs::create_dir_all(market_dir.join(PUBLISHED_DIR)) .await .context("Creating marketplace published dir")?; Ok(()) } /// Load cached marketplace data. pub async fn load_cache(data_dir: &Path) -> Result { let path = data_dir .join(MARKETPLACE_DIR) .join("cache") .join(CACHE_FILE); if !path.exists() { return Ok(MarketplaceCache::default()); } let data = fs::read_to_string(&path) .await .context("Reading marketplace cache")?; serde_json::from_str(&data).context("Parsing marketplace cache") } /// Save marketplace cache. pub async fn save_cache(data_dir: &Path, cache: &MarketplaceCache) -> Result<()> { ensure_dirs(data_dir).await?; let path = data_dir .join(MARKETPLACE_DIR) .join("cache") .join(CACHE_FILE); let data = serde_json::to_string_pretty(cache)?; fs::write(&path, data) .await .context("Writing marketplace cache")?; Ok(()) } /// Validate a manifest meets security requirements. pub fn validate_manifest(manifest: &AppManifest) -> Vec { let mut issues = Vec::new(); // Required fields if manifest.app_id.is_empty() { issues.push("Missing app_id".into()); } if manifest.name.is_empty() { issues.push("Missing name".into()); } if manifest.version.is_empty() { issues.push("Missing version".into()); } if manifest.container.image.is_empty() { issues.push("Missing container image".into()); } // Security checks if manifest.container.image.ends_with(":latest") { issues.push("Container image uses :latest tag (must pin specific version)".into()); } if !manifest.container.readonly_root { issues.push("readonly_root is false (should be true)".into()); } if !manifest.container.no_new_privileges { issues.push("no_new_privileges is false (should be true)".into()); } if manifest.container.run_as_user < 1000 { issues.push(format!( "run_as_user is {} (must be >= 1000)", manifest.container.run_as_user )); } // app_id format if !manifest .app_id .chars() .all(|c| c.is_ascii_lowercase() || c == '-' || c.is_ascii_digit()) { issues.push("app_id must be lowercase kebab-case".into()); } issues } /// Calculate trust score for a discovered app manifest. pub fn calculate_trust_score( manifest: &AppManifest, relay_count: u32, federated_dids: &[String], ) -> (u32, String) { let mut score: u32 = 0; // DID verification (30 points) — has a valid DID in author if !manifest.author.did.is_empty() && manifest.author.did.starts_with("did:") { score += 30; } // Relay consensus (20 points) — found on multiple relays score += match relay_count { 0..=1 => 5, 2..=3 => 12, _ => 20, }; // Federation trust (20 points) — developer DID in federation if federated_dids.contains(&manifest.author.did) { score += 20; } // Version history (15 points) — has a proper semver version if manifest.version.split('.').count() == 3 { score += 10; } if !manifest.repo_url.is_empty() { score += 5; } // Security compliance (15 points) let issues = validate_manifest(manifest); if issues.is_empty() { score += 15; } else if issues.len() <= 2 { score += 5; } let tier = match score { 80..=100 => "verified", 50..=79 => "community", 20..=49 => "unverified", _ => "untrusted", }; (score, tier.to_string()) } /// Discover app manifests from Nostr relays. /// /// Queries configured relays for kind 30078 events with the marketplace tag, /// parses manifests, validates, scores, and returns sorted by trust score. pub async fn discover( data_dir: &Path, relays: &[String], tor_proxy: Option<&str>, federated_dids: &[String], ) -> Result> { if relays.is_empty() { return Ok(Vec::new()); } info!( relay_count = relays.len(), "Discovering marketplace apps from Nostr relays" ); let anon_keys = nostr_sdk::prelude::Keys::generate(); let client = build_nostr_client(anon_keys, tor_proxy)?; for url in relays { let _ = client.add_relay(url).await; } if tokio::time::timeout(Duration::from_secs(10), client.connect()) .await .is_err() { tracing::warn!("Nostr relay connection timed out after 10s, continuing anyway"); } let filter = nostr_sdk::prelude::Filter::new() .kind(nostr_sdk::prelude::Kind::Custom(ARCHIPELAGO_KIND as u16)) .hashtag(MARKETPLACE_TAG) .limit(200); let events = client .fetch_events(filter, std::time::Duration::from_secs(20)) .await .map(|e| e.to_vec()) .unwrap_or_default(); client.disconnect().await; debug!( event_count = events.len(), "Fetched marketplace events from relays" ); // Deduplicate by app_id, keeping the latest version let mut app_map: HashMap = HashMap::new(); for event in events { // Parse manifest from event content let manifest: AppManifest = match serde_json::from_str(&event.content) { Ok(m) => m, Err(e) => { debug!(err = %e, "Skipping invalid marketplace manifest"); continue; } }; // Validate let issues = validate_manifest(&manifest); if issues .iter() .any(|i| i.contains("Missing app_id") || i.contains("Missing container image")) { debug!(issues = ?issues, "Skipping manifest with critical issues"); continue; } let app_id = manifest.app_id.clone(); let entry = app_map.entry(app_id).or_insert_with(|| { let (trust_score, trust_tier) = calculate_trust_score(&manifest, 1, federated_dids); ( DiscoveredApp { manifest, trust_score, trust_tier, relay_count: 0, first_seen: event.created_at.to_human_datetime(), nostr_pubkey: event.pubkey.to_hex(), }, 0, ) }); entry.1 += 1; } // Update relay counts and recalculate scores let mut apps: Vec = app_map .into_values() .map(|(mut app, relay_count)| { app.relay_count = relay_count; let (score, tier) = calculate_trust_score(&app.manifest, relay_count, federated_dids); app.trust_score = score; app.trust_tier = tier; app }) .collect(); // Sort by trust score descending apps.sort_by(|a, b| b.trust_score.cmp(&a.trust_score)); // Cache results let cache = MarketplaceCache { apps: apps.clone(), last_updated: chrono::Utc::now().to_rfc3339(), }; if let Err(e) = save_cache(data_dir, &cache).await { warn!(err = %e, "Failed to save marketplace cache"); } info!(app_count = apps.len(), "Marketplace discovery complete"); Ok(apps) } /// Publish an app manifest to Nostr relays. pub async fn publish( data_dir: &Path, manifest: &AppManifest, relays: &[String], tor_proxy: Option<&str>, ) -> Result { if relays.is_empty() { anyhow::bail!("No relays configured for publishing"); } let issues = validate_manifest(manifest); if !issues.is_empty() { anyhow::bail!("Manifest validation failed: {}", issues.join(", ")); } let identity_dir = data_dir.join("identity"); let keys = load_or_create_keys(&identity_dir).await?; let client = build_nostr_client(keys, tor_proxy)?; let content = serde_json::to_string(manifest).context("Serializing manifest")?; let d_tag = format!("{}{}", D_TAG_PREFIX, manifest.app_id); for url in relays { let _ = client.add_relay(url).await; } if tokio::time::timeout(Duration::from_secs(10), client.connect()) .await .is_err() { tracing::warn!("Nostr relay connection timed out after 10s, continuing anyway"); } let builder = nostr_sdk::prelude::EventBuilder::new( nostr_sdk::prelude::Kind::Custom(ARCHIPELAGO_KIND as u16), &content, ) .tag(nostr_sdk::prelude::Tag::identifier(&d_tag)) .tag(nostr_sdk::prelude::Tag::hashtag(MARKETPLACE_TAG)) .tag(nostr_sdk::prelude::Tag::hashtag(format!( "category:{}", manifest.category ))) .tag(nostr_sdk::prelude::Tag::custom( nostr_sdk::prelude::TagKind::custom("version"), [&manifest.version], )) .tag(nostr_sdk::prelude::Tag::custom( nostr_sdk::prelude::TagKind::custom("image"), [&manifest.container.image], )); let output = client.send_event_builder(builder).await?; client.disconnect().await; // Save to published directory ensure_dirs(data_dir).await?; let pub_path = data_dir .join(MARKETPLACE_DIR) .join(PUBLISHED_DIR) .join(format!("{}.json", manifest.app_id)); let pub_data = serde_json::to_string_pretty(manifest)?; fs::write(&pub_path, pub_data) .await .context("Saving published manifest")?; info!(app_id = %manifest.app_id, "Published app manifest to {} relays", relays.len()); Ok(output.id().to_hex()) } /// List manifests published by this node. pub async fn list_published(data_dir: &Path) -> Result> { let pub_dir = data_dir.join(MARKETPLACE_DIR).join(PUBLISHED_DIR); if !pub_dir.exists() { return Ok(Vec::new()); } let mut manifests = Vec::new(); let mut entries = fs::read_dir(&pub_dir) .await .context("Reading published dir")?; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); if path.extension().map(|e| e == "json").unwrap_or(false) { let data = fs::read_to_string(&path).await?; if let Ok(manifest) = serde_json::from_str::(&data) { manifests.push(manifest); } } } Ok(manifests) } // Re-use nostr client builder pattern from nostr_discovery fn build_nostr_client( keys: nostr_sdk::prelude::Keys, tor_proxy: Option<&str>, ) -> Result { use nostr_sdk::prelude::*; let client = if let Some(proxy_str) = tor_proxy { let addr: std::net::SocketAddr = proxy_str .trim() .parse() .ok() .ok_or_else(|| anyhow::anyhow!("Invalid Tor proxy: {}", proxy_str))?; let connection = Connection::new().proxy(addr).target(ConnectionTarget::All); let opts = ClientOptions::new().connection(connection); Client::builder().signer(keys).opts(opts).build() } else { Client::new(keys) }; Ok(client) } /// Load or create Nostr keys for marketplace publishing. async fn load_or_create_keys(identity_dir: &Path) -> Result { use nostr_sdk::prelude::Keys; let secret_path = identity_dir.join("nostr_secret"); if secret_path.exists() { let hex_secret = fs::read_to_string(&secret_path) .await .context("Reading Nostr secret")?; Keys::parse(hex_secret.trim()).context("Invalid Nostr secret") } else { let keys = Keys::generate(); fs::create_dir_all(identity_dir).await?; fs::write(&secret_path, keys.secret_key().to_secret_hex()).await?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; tokio::fs::set_permissions(&secret_path, std::fs::Permissions::from_mode(0o600)) .await?; } Ok(keys) } } #[cfg(test)] mod tests { use super::*; fn sample_manifest() -> AppManifest { AppManifest { app_id: "test-app".into(), name: "Test App".into(), version: "1.0.0".into(), description: ManifestDescription::Detailed { short: "A test app".into(), long: "A longer description of the test app".into(), }, author: ManifestAuthor { name: "Test Dev".into(), did: "did:key:z6MkTest123".into(), nostr_pubkey: String::new(), }, container: ManifestContainer { image: "docker.io/test/app:1.0.0".into(), ports: vec![PortMapping { container: 8080, host: 8180, protocol: "tcp".into(), }], volumes: vec![], env: HashMap::new(), capabilities: vec![], readonly_root: true, no_new_privileges: true, run_as_user: 1000, }, category: AppCategory::Other, icon_url: String::new(), repo_url: "https://github.com/test/app".into(), license: "MIT".into(), min_archipelago_version: "0.1.0".into(), dependencies: vec![], signatures: None, } } #[test] fn test_validate_valid_manifest() { let manifest = sample_manifest(); let issues = validate_manifest(&manifest); assert!(issues.is_empty(), "Expected no issues, got: {:?}", issues); } #[test] fn test_validate_latest_tag() { let mut manifest = sample_manifest(); manifest.container.image = "docker.io/test/app:latest".into(); let issues = validate_manifest(&manifest); assert!(issues.iter().any(|i| i.contains("latest"))); } #[test] fn test_validate_root_user() { let mut manifest = sample_manifest(); manifest.container.run_as_user = 0; let issues = validate_manifest(&manifest); assert!(issues.iter().any(|i| i.contains("run_as_user"))); } #[test] fn test_validate_missing_fields() { let mut manifest = sample_manifest(); manifest.app_id = String::new(); manifest.container.image = String::new(); let issues = validate_manifest(&manifest); assert!(issues.len() >= 2); } #[test] fn test_trust_score_full() { let manifest = sample_manifest(); let (score, tier) = calculate_trust_score(&manifest, 3, &["did:key:z6MkTest123".to_string()]); // DID (30) + relay consensus 2-3 (12) + federation (20) + semver (10) + repo (5) + security clean (15) = 92 assert!(score >= 80, "Expected verified, got score={}", score); assert_eq!(tier, "verified"); } #[test] fn test_trust_score_no_federation() { let manifest = sample_manifest(); let (score, tier) = calculate_trust_score(&manifest, 1, &[]); // DID (30) + 1 relay (5) + no federation (0) + semver (10) + repo (5) + security (15) = 65 assert_eq!(tier, "community"); assert!((50..80).contains(&score)); } #[test] fn test_trust_score_untrusted() { let mut manifest = sample_manifest(); manifest.author.did = String::new(); manifest.repo_url = String::new(); manifest.version = "1".into(); manifest.container.readonly_root = false; let (score, _tier) = calculate_trust_score(&manifest, 1, &[]); assert!(score < 50, "Expected low score, got {}", score); } #[test] fn test_manifest_serialization() { let manifest = sample_manifest(); let json = serde_json::to_string(&manifest).unwrap(); let parsed: AppManifest = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.app_id, "test-app"); assert_eq!(parsed.description.short(), "A test app"); } #[test] fn test_category_display() { assert_eq!(AppCategory::Money.to_string(), "money"); assert_eq!(AppCategory::Networking.to_string(), "networking"); } #[tokio::test] async fn test_cache_persistence() { let dir = tempfile::tempdir().unwrap(); let cache = MarketplaceCache { apps: vec![DiscoveredApp { manifest: sample_manifest(), trust_score: 75, trust_tier: "community".into(), relay_count: 2, first_seen: "2026-03-10T00:00:00Z".into(), nostr_pubkey: "abc123".into(), }], last_updated: "2026-03-10T00:00:00Z".into(), }; save_cache(dir.path(), &cache).await.unwrap(); let loaded = load_cache(dir.path()).await.unwrap(); assert_eq!(loaded.apps.len(), 1); assert_eq!(loaded.apps[0].manifest.app_id, "test-app"); } }