1189 lines
41 KiB
Rust
1189 lines
41 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 },
|
|
}
|
|
|
|
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,
|
|
/// Outcome of checking the manifest's DID signature. `#[serde(default)]`
|
|
/// so a cache written before this field existed still loads (as `missing`,
|
|
/// which is the honest answer for an entry we never verified).
|
|
#[serde(default)]
|
|
pub signature: SignatureStatus,
|
|
}
|
|
|
|
// ─── DID signature layer ────────────────────────────────────────────────
|
|
//
|
|
// A marketplace manifest travels inside a Nostr event, so it already carries a
|
|
// NIP-01 Schnorr signature proving *the publishing relay key* sent it. That
|
|
// says nothing about the `author.did` the manifest claims. This layer closes
|
|
// that gap: the author signs a digest of their own manifest with the Ed25519
|
|
// key their did:key encodes, and every consumer re-derives the digest and
|
|
// checks it.
|
|
//
|
|
// Until this existed, `signatures.manifest_hash` / `signatures.did_signature`
|
|
// were struct fields nothing read, and the trust score awarded 30 points for
|
|
// `did.starts_with("did:")` — i.e. for typing a string.
|
|
|
|
/// Outcome of checking a manifest's `signatures` block.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "status", rename_all = "lowercase")]
|
|
pub enum SignatureStatus {
|
|
/// `manifest_hash` matches the content and `did_signature` verifies against
|
|
/// the key in `author.did`.
|
|
Valid,
|
|
/// No `signatures` block. Not an attack — an unsigned publisher — but it
|
|
/// earns none of the identity-derived trust.
|
|
#[default]
|
|
Missing,
|
|
/// A `signatures` block is present and wrong: corrupt, tampered with, or
|
|
/// signed by a key other than the one `author.did` names.
|
|
Invalid { reason: String },
|
|
}
|
|
|
|
impl SignatureStatus {
|
|
pub fn is_valid(&self) -> bool {
|
|
matches!(self, Self::Valid)
|
|
}
|
|
}
|
|
|
|
/// Recursively rebuild a JSON value with every object's keys in lexicographic
|
|
/// order, so the signed preimage is byte-stable.
|
|
///
|
|
/// This is not belt-and-braces. `ManifestContainer::env` is a `HashMap`, whose
|
|
/// iteration order is randomised per process; and `serde_json::Map` is only a
|
|
/// sorted `BTreeMap` while the `preserve_order` feature is off — a feature any
|
|
/// crate anywhere in the dependency graph can turn on for everyone through
|
|
/// Cargo feature unification. Either way the digest would start changing
|
|
/// between runs and every signature would break. Sorting here makes the
|
|
/// preimage independent of both.
|
|
fn canonicalize(value: serde_json::Value) -> serde_json::Value {
|
|
match value {
|
|
serde_json::Value::Object(map) => {
|
|
let mut pairs: Vec<(String, serde_json::Value)> = map.into_iter().collect();
|
|
pairs.sort_by(|a, b| a.0.cmp(&b.0));
|
|
let mut out = serde_json::Map::new();
|
|
for (k, v) in pairs {
|
|
out.insert(k, canonicalize(v));
|
|
}
|
|
serde_json::Value::Object(out)
|
|
}
|
|
serde_json::Value::Array(items) => {
|
|
serde_json::Value::Array(items.into_iter().map(canonicalize).collect())
|
|
}
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
/// The exact bytes a manifest signature covers: the manifest as canonical JSON
|
|
/// (sorted keys, no whitespace) with the `signatures` block itself omitted —
|
|
/// a signature cannot cover the field that holds it.
|
|
pub fn canonical_signing_bytes(manifest: &AppManifest) -> Result<Vec<u8>> {
|
|
let mut unsigned = manifest.clone();
|
|
unsigned.signatures = None;
|
|
let value = serde_json::to_value(&unsigned).context("Serializing manifest for signing")?;
|
|
serde_json::to_vec(&canonicalize(value)).context("Encoding canonical manifest JSON")
|
|
}
|
|
|
|
/// SHA-256 over [`canonical_signing_bytes`]. This digest is what gets signed,
|
|
/// and what `signatures.manifest_hash` records as `sha256:<hex>`.
|
|
pub fn manifest_digest(manifest: &AppManifest) -> Result<[u8; 32]> {
|
|
use sha2::{Digest, Sha256};
|
|
Ok(Sha256::digest(canonical_signing_bytes(manifest)?).into())
|
|
}
|
|
|
|
/// Sign `manifest` in place with an Ed25519 key, filling in `signatures`.
|
|
///
|
|
/// The caller must ensure `author.did` is the did:key for `signing_key` —
|
|
/// [`publish`] enforces that. Signing with a mismatched key produces a manifest
|
|
/// that every verifier rejects.
|
|
pub fn sign_manifest(
|
|
manifest: &mut AppManifest,
|
|
signing_key: &ed25519_dalek::SigningKey,
|
|
) -> Result<()> {
|
|
use ed25519_dalek::Signer;
|
|
// Clear first so a re-sign never covers a previous signature.
|
|
manifest.signatures = None;
|
|
let digest = manifest_digest(manifest)?;
|
|
let signature = signing_key.sign(&digest);
|
|
manifest.signatures = Some(ManifestSignatures {
|
|
manifest_hash: format!("sha256:{}", hex::encode(digest)),
|
|
did_signature: base64::Engine::encode(
|
|
&base64::engine::general_purpose::STANDARD,
|
|
signature.to_bytes(),
|
|
),
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
/// Verify a manifest's `signatures` block against its own content and the
|
|
/// Ed25519 key encoded in `author.did`.
|
|
///
|
|
/// Never returns an error: a manifest arriving off a public relay is untrusted
|
|
/// input, and every way it can be wrong is a verdict rather than an exception.
|
|
pub fn verify_manifest_signature(manifest: &AppManifest) -> SignatureStatus {
|
|
use ed25519_dalek::Verifier;
|
|
|
|
let invalid = |reason: &str| SignatureStatus::Invalid {
|
|
reason: reason.to_string(),
|
|
};
|
|
|
|
let sigs = match &manifest.signatures {
|
|
Some(s) => s,
|
|
None => return SignatureStatus::Missing,
|
|
};
|
|
|
|
let digest = match manifest_digest(manifest) {
|
|
Ok(d) => d,
|
|
Err(_) => return invalid("manifest could not be canonicalized"),
|
|
};
|
|
|
|
// 1. Content integrity: does the recorded hash describe this manifest?
|
|
let claimed_hex = match sigs.manifest_hash.strip_prefix("sha256:") {
|
|
Some(h) => h,
|
|
None => return invalid("manifest_hash is not in sha256:<hex> form"),
|
|
};
|
|
match hex::decode(claimed_hex) {
|
|
Ok(claimed) if claimed == digest => {}
|
|
Ok(_) => return invalid("manifest_hash does not match the manifest content"),
|
|
Err(_) => return invalid("manifest_hash is not valid hex"),
|
|
}
|
|
|
|
// 2. Identity: resolve the DID to a key.
|
|
let pubkey_bytes = match crate::identity::pubkey_bytes_from_did_key(&manifest.author.did) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
return SignatureStatus::Invalid {
|
|
reason: format!("author.did is not a resolvable Ed25519 did:key: {e}"),
|
|
}
|
|
}
|
|
};
|
|
let verifying_key = match ed25519_dalek::VerifyingKey::from_bytes(&pubkey_bytes) {
|
|
Ok(k) => k,
|
|
Err(_) => return invalid("author.did does not encode a valid Ed25519 key"),
|
|
};
|
|
|
|
// 3. Authenticity: did that key sign this digest?
|
|
let sig_bytes = match base64::Engine::decode(
|
|
&base64::engine::general_purpose::STANDARD,
|
|
&sigs.did_signature,
|
|
) {
|
|
Ok(b) => b,
|
|
Err(_) => return invalid("did_signature is not valid base64"),
|
|
};
|
|
let sig_array: [u8; 64] = match sig_bytes.as_slice().try_into() {
|
|
Ok(a) => a,
|
|
Err(_) => return invalid("did_signature is not a 64-byte Ed25519 signature"),
|
|
};
|
|
|
|
match verifying_key.verify(&digest, &ed25519_dalek::Signature::from_bytes(&sig_array)) {
|
|
Ok(()) => SignatureStatus::Valid,
|
|
Err(_) => invalid("did_signature does not verify against author.did"),
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
/// `signature` gates both identity-derived factors. Pass the result of
|
|
/// [`verify_manifest_signature`].
|
|
pub fn calculate_trust_score(
|
|
manifest: &AppManifest,
|
|
relay_count: u32,
|
|
federated_dids: &[String],
|
|
signature: &SignatureStatus,
|
|
) -> (u32, String) {
|
|
let mut score: u32 = 0;
|
|
|
|
// Identity (30 points) — the author proved control of the key their
|
|
// did:key names. This used to be `did.starts_with("did:")`, i.e. a string
|
|
// test any publisher could pass by typing one, which made the whole
|
|
// "Verified" tier meaningless.
|
|
let identity_proven = signature.is_valid();
|
|
if identity_proven {
|
|
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.
|
|
//
|
|
// Also gated on the signature: an unverified `author.did` is just a string
|
|
// the publisher chose, so without this an attacker could copy a DID the
|
|
// user federates with and collect 20 points for impersonating them —
|
|
// exactly the peer they trust most.
|
|
if identity_proven && 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;
|
|
}
|
|
|
|
// Check the DID signature before the manifest is allowed anywhere near
|
|
// the cache. A *wrong* signature is not a low-trust manifest, it is a
|
|
// corrupt or forged one — drop it rather than listing it at reduced
|
|
// score, so it can never be installed. A *missing* signature is
|
|
// different: an unsigned publisher is legitimate, just unproven, and
|
|
// scores zero on the identity factors.
|
|
let signature = verify_manifest_signature(&manifest);
|
|
if let SignatureStatus::Invalid { reason } = &signature {
|
|
warn!(
|
|
app_id = %manifest.app_id,
|
|
author_did = %manifest.author.did,
|
|
nostr_pubkey = %event.pubkey.to_hex(),
|
|
reason = %reason,
|
|
"Rejecting marketplace manifest with a bad DID signature"
|
|
);
|
|
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, &signature);
|
|
(
|
|
DiscoveredApp {
|
|
manifest,
|
|
trust_score,
|
|
trust_tier,
|
|
relay_count: 0,
|
|
first_seen: event.created_at.to_human_datetime(),
|
|
nostr_pubkey: event.pubkey.to_hex(),
|
|
signature,
|
|
},
|
|
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.signature);
|
|
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");
|
|
|
|
// Sign with the node's Ed25519 identity key — the same key its did:key
|
|
// encodes — so consumers can verify authorship independently of whichever
|
|
// Nostr key happens to relay the event.
|
|
let identity = crate::identity::NodeIdentity::load_or_create(&identity_dir)
|
|
.await
|
|
.context("Loading node identity to sign the manifest")?;
|
|
let our_did = identity.did_key().context("Deriving this node's did:key")?;
|
|
|
|
let mut manifest = manifest.clone();
|
|
if manifest.author.did.is_empty() {
|
|
manifest.author.did = our_did.clone();
|
|
} else if manifest.author.did != our_did {
|
|
// We can only sign as ourselves. Publishing under someone else's DID
|
|
// would produce a manifest every verifier rejects, so fail loudly here
|
|
// instead of broadcasting garbage to every relay.
|
|
anyhow::bail!(
|
|
"Cannot publish as author.did {} — this node can only sign as {}",
|
|
manifest.author.did,
|
|
our_did
|
|
);
|
|
}
|
|
sign_manifest(&mut manifest, identity.signing_key()).context("Signing manifest")?;
|
|
debug_assert!(verify_manifest_signature(&manifest).is_valid());
|
|
let manifest = &manifest;
|
|
|
|
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);
|
|
}
|
|
|
|
/// A real Ed25519 keypair, its did:key, and a manifest signed by it.
|
|
fn signed_manifest() -> (ed25519_dalek::SigningKey, String, AppManifest) {
|
|
let key = ed25519_dalek::SigningKey::generate(&mut rand::rngs::OsRng);
|
|
let did =
|
|
crate::identity::did_key_from_pubkey_hex(&hex::encode(key.verifying_key().as_bytes()))
|
|
.unwrap();
|
|
let mut manifest = sample_manifest();
|
|
manifest.author.did = did.clone();
|
|
sign_manifest(&mut manifest, &key).unwrap();
|
|
(key, did, manifest)
|
|
}
|
|
|
|
#[test]
|
|
fn verify_accepts_a_properly_signed_manifest() {
|
|
let (_key, _did, manifest) = signed_manifest();
|
|
assert_eq!(verify_manifest_signature(&manifest), SignatureStatus::Valid);
|
|
}
|
|
|
|
#[test]
|
|
fn verify_reports_missing_when_there_is_no_signature_block() {
|
|
let manifest = sample_manifest();
|
|
assert_eq!(
|
|
verify_manifest_signature(&manifest),
|
|
SignatureStatus::Missing
|
|
);
|
|
}
|
|
|
|
/// Tampering with any covered field must break the hash check.
|
|
#[test]
|
|
fn verify_rejects_a_tampered_field() {
|
|
let (_key, _did, mut manifest) = signed_manifest();
|
|
manifest.container.image = "docker.io/evil/backdoor:1.0.0".into();
|
|
match verify_manifest_signature(&manifest) {
|
|
SignatureStatus::Invalid { reason } => {
|
|
assert!(reason.contains("does not match"), "reason: {reason}")
|
|
}
|
|
other => panic!("tampered manifest accepted: {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// The interesting attack: tamper with the content AND recompute
|
|
/// `manifest_hash` so the integrity check passes. Without the key the
|
|
/// signature can't be regenerated, so this must still fail.
|
|
#[test]
|
|
fn verify_rejects_tampering_that_also_rewrites_the_hash() {
|
|
let (_key, _did, mut manifest) = signed_manifest();
|
|
let stolen_signature = manifest.signatures.clone().unwrap().did_signature;
|
|
|
|
manifest.container.image = "docker.io/evil/backdoor:1.0.0".into();
|
|
let new_digest = manifest_digest(&manifest).unwrap();
|
|
manifest.signatures = Some(ManifestSignatures {
|
|
manifest_hash: format!("sha256:{}", hex::encode(new_digest)),
|
|
did_signature: stolen_signature,
|
|
});
|
|
|
|
match verify_manifest_signature(&manifest) {
|
|
SignatureStatus::Invalid { reason } => {
|
|
assert!(reason.contains("does not verify"), "reason: {reason}")
|
|
}
|
|
other => panic!("forged manifest accepted: {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Signing with one key while claiming another's DID must fail — this is
|
|
/// the impersonation case the whole layer exists to stop.
|
|
#[test]
|
|
fn verify_rejects_a_signature_from_a_key_other_than_the_claimed_did() {
|
|
let (_key_a, _did_a, mut manifest) = signed_manifest();
|
|
let (_key_b, did_b, _) = signed_manifest();
|
|
manifest.author.did = did_b;
|
|
assert!(
|
|
!verify_manifest_signature(&manifest).is_valid(),
|
|
"a manifest signed by A must not verify as B"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn verify_rejects_unusable_author_dids_and_malformed_signatures() {
|
|
let (key, _did, base) = signed_manifest();
|
|
|
|
// Not a did:key at all.
|
|
let mut m = base.clone();
|
|
m.author.did = "did:web:example.com".into();
|
|
assert!(!verify_manifest_signature(&m).is_valid());
|
|
|
|
// did:key shaped but not decodable — the old code scored this 30/30.
|
|
let mut m = base.clone();
|
|
m.author.did = "did:key:z6MkTest123".into();
|
|
assert!(!verify_manifest_signature(&m).is_valid());
|
|
|
|
// Signature that isn't base64.
|
|
let mut m = base.clone();
|
|
m.signatures.as_mut().unwrap().did_signature = "not!base64!".into();
|
|
assert!(!verify_manifest_signature(&m).is_valid());
|
|
|
|
// Base64 of the wrong length.
|
|
let mut m = base.clone();
|
|
m.signatures.as_mut().unwrap().did_signature =
|
|
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [0u8; 16]);
|
|
assert!(!verify_manifest_signature(&m).is_valid());
|
|
|
|
// Hash in the wrong form.
|
|
let mut m = base.clone();
|
|
m.signatures.as_mut().unwrap().manifest_hash = "deadbeef".into();
|
|
assert!(!verify_manifest_signature(&m).is_valid());
|
|
|
|
// Sanity: the untouched manifest still verifies, so the cases above
|
|
// failed for their own reasons and not because the fixture is broken.
|
|
let mut ok = base;
|
|
sign_manifest(&mut ok, &key).unwrap();
|
|
assert!(verify_manifest_signature(&ok).is_valid());
|
|
}
|
|
|
|
/// `env` is a HashMap, whose iteration order is randomised per process. If
|
|
/// the preimage were not canonicalised, the same manifest would hash
|
|
/// differently between runs and signatures would fail at random.
|
|
#[test]
|
|
fn digest_is_stable_regardless_of_map_insertion_order() {
|
|
let mut a = sample_manifest();
|
|
a.container.env.insert("ZEBRA".into(), "1".into());
|
|
a.container.env.insert("ALPHA".into(), "2".into());
|
|
a.container.env.insert("MIDDLE".into(), "3".into());
|
|
|
|
let mut b = sample_manifest();
|
|
b.container.env.insert("MIDDLE".into(), "3".into());
|
|
b.container.env.insert("ALPHA".into(), "2".into());
|
|
b.container.env.insert("ZEBRA".into(), "1".into());
|
|
|
|
assert_eq!(manifest_digest(&a).unwrap(), manifest_digest(&b).unwrap());
|
|
assert_eq!(
|
|
canonical_signing_bytes(&a).unwrap(),
|
|
canonical_signing_bytes(&b).unwrap()
|
|
);
|
|
}
|
|
|
|
/// The signature must not cover the field that holds it, or re-signing an
|
|
/// already-signed manifest would produce a different digest each time.
|
|
#[test]
|
|
fn digest_ignores_the_signatures_block() {
|
|
let (key, _did, signed) = signed_manifest();
|
|
let mut unsigned = signed.clone();
|
|
unsigned.signatures = None;
|
|
assert_eq!(
|
|
manifest_digest(&signed).unwrap(),
|
|
manifest_digest(&unsigned).unwrap()
|
|
);
|
|
|
|
// Re-signing is stable (Ed25519 is deterministic).
|
|
let mut resigned = signed.clone();
|
|
sign_manifest(&mut resigned, &key).unwrap();
|
|
assert_eq!(
|
|
resigned.signatures.unwrap().did_signature,
|
|
signed.signatures.unwrap().did_signature
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_trust_score_full() {
|
|
let (_key, did, manifest) = signed_manifest();
|
|
let signature = verify_manifest_signature(&manifest);
|
|
let (score, tier) = calculate_trust_score(&manifest, 3, &[did], &signature);
|
|
// identity (30) + relays 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 (_key, _did, manifest) = signed_manifest();
|
|
let signature = verify_manifest_signature(&manifest);
|
|
let (score, tier) = calculate_trust_score(&manifest, 1, &[], &signature);
|
|
// identity (30) + 1 relay (5) + 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, &[], &SignatureStatus::Missing);
|
|
assert!(score < 50, "Expected low score, got {score}");
|
|
}
|
|
|
|
/// The regression that made "Verified" meaningless: an unsigned manifest
|
|
/// with a plausible-looking DID string used to score 30/30 on identity and
|
|
/// land at 65 — "Community" — on nothing but a `starts_with("did:")`.
|
|
#[test]
|
|
fn an_unsigned_manifest_earns_no_identity_points() {
|
|
let manifest = sample_manifest(); // author.did is "did:key:z6MkTest123"
|
|
let signature = verify_manifest_signature(&manifest);
|
|
assert_eq!(signature, SignatureStatus::Missing);
|
|
|
|
let (score, tier) = calculate_trust_score(&manifest, 1, &[], &signature);
|
|
// 1 relay (5) + semver (10) + repo (5) + security (15) = 35, no identity 30.
|
|
assert_eq!(score, 35);
|
|
assert_eq!(tier, "unverified");
|
|
}
|
|
|
|
/// Impersonation via the federation factor: claiming a DID the user
|
|
/// federates with must earn nothing unless the claim is proven.
|
|
#[test]
|
|
fn claiming_a_federated_did_without_proving_it_earns_no_federation_points() {
|
|
let (_key, victim_did, _) = signed_manifest();
|
|
|
|
let mut impostor = sample_manifest();
|
|
impostor.author.did = victim_did.clone();
|
|
assert_eq!(
|
|
verify_manifest_signature(&impostor),
|
|
SignatureStatus::Missing
|
|
);
|
|
|
|
let federated = [victim_did];
|
|
let (unproven, _) =
|
|
calculate_trust_score(&impostor, 1, &federated, &SignatureStatus::Missing);
|
|
let (proven, _) = calculate_trust_score(&impostor, 1, &federated, &SignatureStatus::Valid);
|
|
|
|
assert_eq!(
|
|
proven - unproven,
|
|
50,
|
|
"identity (30) + federation (20) must both hang off proof"
|
|
);
|
|
}
|
|
|
|
#[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(),
|
|
signature: SignatureStatus::Valid,
|
|
}],
|
|
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");
|
|
assert_eq!(loaded.apps[0].signature, SignatureStatus::Valid);
|
|
}
|
|
|
|
/// A cache written before the signature field existed must still load, and
|
|
/// must come back as unverified rather than silently defaulting to trusted.
|
|
#[tokio::test]
|
|
async fn a_legacy_cache_without_the_signature_field_loads_as_missing() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
ensure_dirs(dir.path()).await.unwrap();
|
|
let legacy = serde_json::json!({
|
|
"apps": [{
|
|
"manifest": sample_manifest(),
|
|
"trust_score": 75,
|
|
"trust_tier": "community",
|
|
"relay_count": 2,
|
|
"first_seen": "2026-03-10T00:00:00Z",
|
|
"nostr_pubkey": "abc123"
|
|
}],
|
|
"last_updated": "2026-03-10T00:00:00Z"
|
|
});
|
|
let path = dir
|
|
.path()
|
|
.join(MARKETPLACE_DIR)
|
|
.join("cache")
|
|
.join(CACHE_FILE);
|
|
fs::write(&path, serde_json::to_vec(&legacy).unwrap())
|
|
.await
|
|
.unwrap();
|
|
|
|
let loaded = load_cache(dir.path()).await.unwrap();
|
|
assert_eq!(loaded.apps.len(), 1);
|
|
assert_eq!(loaded.apps[0].signature, SignatureStatus::Missing);
|
|
}
|
|
|
|
/// `SignatureStatus` crosses the RPC boundary to the UI, so its wire shape
|
|
/// is a contract worth pinning.
|
|
#[test]
|
|
fn signature_status_serialises_to_a_tagged_object() {
|
|
assert_eq!(
|
|
serde_json::to_value(SignatureStatus::Valid).unwrap(),
|
|
serde_json::json!({ "status": "valid" })
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(SignatureStatus::Missing).unwrap(),
|
|
serde_json::json!({ "status": "missing" })
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(SignatureStatus::Invalid {
|
|
reason: "nope".into()
|
|
})
|
|
.unwrap(),
|
|
serde_json::json!({ "status": "invalid", "reason": "nope" })
|
|
);
|
|
}
|
|
}
|