archy/core/archipelago/src/marketplace.rs
Dorian 207e53144c feat: architecture review fixes, self-update system, CI pipeline, supply chain hardening
Architecture review (all P0+P1 issues now fixed):
- Add 10s timeout to 6 bare Nostr client.connect() calls
- Pin all 12 crypto deps to exact versions from Cargo.lock
- Pin all 15 floating container image tags to exact patch versions
- Add CI pipeline (cargo fmt + clippy + tests, frontend type-check + build)

Self-update system (git.tx1138.com):
- scripts/self-update.sh: pull, build, install, restart with rollback
- systemd timer checks daily at 3 AM
- update.check RPC does git-based checks when repo is present
- update.git-apply RPC triggers self-update from UI
- Default update URL changed from GitHub to git.tx1138.com
- Git added to ISO package list for fresh installs

Documentation:
- CHANGELOG v1.3.1 with all changes
- README updated (version, update system section)
- BETA-PROGRESS session #6 logged
- architecture-review.html: 4 issues marked FIXED, 8/12 refactoring done

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:52:26 +00:00

655 lines
21 KiB
Rust

//! Decentralized app marketplace: discover, verify, and publish app manifests
//! via Nostr relays. Uses NIP-78 (kind 30078) with d-tag "archipelago-app:<id>".
//!
//! 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<PortMapping>,
#[serde(default)]
pub volumes: Vec<VolumeMapping>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub capabilities: Vec<String>,
#[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<String>,
#[serde(default)]
pub signatures: Option<ManifestSignatures>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ManifestDescription {
Simple(String),
Detailed { short: String, long: String },
}
/// 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<DiscoveredApp>,
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<MarketplaceCache> {
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<String> {
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<Vec<DiscoveredApp>> {
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<String, (DiscoveredApp, u32)> = 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<DiscoveredApp> = 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<String> {
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<Vec<AppManifest>> {
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::<AppManifest>(&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<nostr_sdk::prelude::Client> {
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<nostr_sdk::prelude::Keys> {
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!(score >= 50 && score < 80);
}
#[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");
}
}