Files
archy/docs/marketplace-protocol.md
T
archipelagoandClaude Opus 5 db60c3382d docs(marketplace-protocol): the DID signature layer is specified but not implemented
This doc is marked "Status: implemented ... shipped end-to-end" and then
describes a cryptographic verification chain that does not exist. On a repo
about to go public, that is the single worst kind of doc bug: it promises a
security property.

`signatures.manifest_hash` / `signatures.did_signature` appear exactly once in
the codebase — as two struct fields at `marketplace.rs:106-107`. Nothing reads
them. There is no hash comparison, no DID resolution, no signature check. The
authenticity actually delivered is the Nostr event's own NIP-01 Schnorr
signature, which proves the publishing key sent the event but says nothing about
the DID the manifest names.

Added a warning at the top, marked the "Manifest Signing (DID Layer)" section
and steps 3-6 of the verification flow as not implemented, and annotated steps
7-8 as advisory (validate_manifest returns scoring issues; it does not block
discovery or install).

The trust model was overstated in the same direction:
- "DID Verification | 30 | Manifest is signed by a valid DID key" is a
  `did.starts_with("did:")` string test. Any publisher can claim any DID and
  take the 30 points.
- "Relay Consensus | 20" is graduated and never zero (1 relay still scores 5).
- "Version History | 15 | multiple published versions (shows maintenance)" —
  nothing counts versions; it's 10 for a 3-part semver plus 5 for a non-empty
  repo_url.
Worked the arithmetic through: an unsigned manifest with a plausible DID string
and a pinned image scores 65, landing in the "Community" tier. Said so.

Other corrections:
- `marketplace.unpublish` is documented but was never implemented (the string
  appears nowhere); removed it and noted why NIP-33 makes it non-trivial. Added
  the two payment methods that do exist (`create-invoice`, `check-payment`).
- The schema section said marketplace manifests "follow the existing
  apps/{app-id}/manifest.yml schema", contradicting the header three paragraphs
  above. They are separate types.
- The security-enforcement list claimed a capability allow-list, a
  host-networking ban and system-path mount restrictions. Those rules are real
  but live in the runtime manifest parser for a different schema — marketplace
  validation checks four things and gates none of them.
- `run_as_user` documented as "> 1000" in two places while the code checks
  `>= 1000` and the doc's own example uses 1000.
- Data-storage tree listed `cache/trust-scores.json` and `config.json`; neither
  is ever written.
- The 15-minute cache TTL and 30-minute background refresh don't exist —
  discovery is RPC-triggered and the cache has no expiry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:59:45 -04:00

16 KiB
Raw Blame History

Decentralized App Marketplace Protocol

Status: implemented (updated 2026-07-08). This started as a protocol proposal; the described subsystem is now shipped end-to-end — core/archipelago/src/marketplace.rs (discover/publish/trust scoring), the marketplace.* RPC namespace, and Marketplace.vue. Beyond this doc, the code also adds marketplace.create-invoice (Lightning BOLT11 app purchases). What remains is maturation: publishing tooling and trust UX (see ROADMAP.md). Note: the manifest schema below is the marketplace's own flatter format, not the runtime apps/*/manifest.yml schema (app-manifest-spec.md).

⚠️ The DID signature layer is specified here but NOT implemented. signatures.manifest_hash and signatures.did_signature exist as fields on the manifest struct (marketplace.rs:106-107) and nothing anywhere in the codebase reads them — there is no hash comparison and no signature check. The authenticity you actually get today is the Nostr event signature (NIP-01 Schnorr, verified by the client library), which proves the event came from the publishing key. It does not prove the manifest was signed by the DID it names. Sections marked (not implemented) below are design, not behaviour. Treat this as the gap to close before third-party publishing opens.

Overview

Archipelago's community marketplace enables developers to publish app manifests to Nostr relays, where nodes discover and install them without a central app store. Trust is established through DID-signed manifests and community reputation.

Architecture

Developer Node                    Nostr Relays                    User Node
     │                                │                               │
     │── Publish signed manifest ──►  │                               │
     │   (NIP-78, kind 30078)         │                               │
     │                                │  ◄── Query app manifests ──   │
     │                                │      (filter by d-tag)        │
     │                                │                               │
     │                                │── Return signed manifests ──► │
     │                                │                               │
     │                                │      [Verify DID signature]   │
     │                                │      [Check trust score]      │
     │                                │      [Display in marketplace] │
     │                                │                               │
     │                                │      [User clicks Install]    │
     │                                │      [Pull container image]   │
     │                                │      [Start container]        │

Manifest Schema

App manifests published to Nostr relays use the marketplace's own flatter JSON schema — the AppManifest type in marketplace.rs, shown below — serialized into the Nostr event's content. It is not the runtime apps/{app-id}/manifest.yml schema in app-manifest-spec.md; the two are separate types that happen to share a name.

Marketplace Manifest Fields

{
  "app_id": "my-bitcoin-tool",
  "name": "My Bitcoin Tool",
  "version": "1.2.0",
  "description": {
    "short": "A useful Bitcoin utility",
    "long": "Detailed description of what this app does..."
  },
  "author": {
    "name": "Developer Name",
    "did": "did:key:z6Mkh...",
    "nostr_pubkey": "npub1..."
  },
  "container": {
    "image": "docker.io/developer/my-bitcoin-tool:1.2.0",
    "ports": [{ "container": 8080, "host": 8180, "protocol": "tcp" }],
    "volumes": [{ "name": "data", "path": "/data" }],
    "env": {
      "NETWORK": "mainnet"
    },
    "capabilities": [],
    "readonly_root": true,
    "no_new_privileges": true,
    "run_as_user": 1000
  },
  "category": "money",
  "icon_url": "https://example.com/icon.png",
  "repo_url": "https://github.com/developer/my-bitcoin-tool",
  "license": "MIT",
  "min_archipelago_version": "0.1.0",
  "dependencies": [],
  "signatures": {
    "manifest_hash": "sha256:abc123...",
    "did_signature": "base64-encoded-signature"
  }
}

Required Fields

Field Type Description
app_id string Unique identifier, lowercase kebab-case
name string Human-readable display name
version string Semantic version (major.minor.patch)
description.short string One-line description (max 120 chars)
author.did string Developer's DID (did:key method)
container.image string Full container image reference with tag (never latest)
category string One of: money, commerce, data, networking, home, community, other

Security-Required Fields

Field Default Description
container.readonly_root true Container root filesystem is read-only
container.no_new_privileges true Prevent privilege escalation
container.run_as_user 1000 UID to run as (must be ≥ 1000)
container.capabilities [] Required Linux capabilities (drop all, add only needed)

Nostr Event Format

Event Kind

App manifests use NIP-78 application-specific data with event kind 30078 (replaceable parameterized). This matches the existing node discovery pattern in nostr_discovery.rs.

Event Structure

{
  "kind": 30078,
  "tags": [
    ["d", "archipelago-app:<app_id>"],
    ["t", "archipelago-marketplace"],
    ["t", "category:<category>"],
    ["version", "<semver>"],
    ["image", "<container_image>"],
    ["L", "archipelago"],
    ["l", "app-manifest", "archipelago"]
  ],
  "content": "<JSON-serialized manifest>",
  "created_at": 1710000000,
  "pubkey": "<developer's secp256k1 pubkey hex>",
  "sig": "<schnorr signature>"
}

Tag Semantics

Tag Purpose
d Unique identifier for NIP-33 replaceable events. Format: archipelago-app:<app_id>
t Searchable topic tags for relay filtering
version Allows version-specific queries
image Container image for quick display without parsing content
L/l NIP-32 labeling namespace for structured queries

Publishing a Manifest

  1. Developer creates/updates their app manifest
  2. Serialize manifest as JSON
  3. Compute SHA-256 hash of the serialized manifest
  4. Sign the hash with the developer's DID key
  5. Embed manifest + signature in Nostr event content
  6. Sign the Nostr event with the node's secp256k1 key
  7. Publish to all configured Nostr relays

Discovering Manifests

  1. Node queries configured relays with filter:
    {
      "kinds": [30078],
      "limit": 100,
      "#t": ["archipelago-marketplace"]
    }
    
  2. For each returned event: a. Verify Nostr event signature (standard NIP-01) b. Parse manifest JSON from content c. Verify DID signature on manifest hash d. Check manifest against security requirements e. Calculate trust score
  3. Return manifests sorted by trust score

Trust Model

Trust Score Calculation

Each discovered app receives a trust score (0-100) based on:

This table is calculate_trust_score() in marketplace.rs:258-306, and several factors are weaker than their names suggest:

Factor Max What is actually checked
DID present 30 author.did is non-empty and starts with did: — a string prefix test, not a signature check. Any publisher can claim any DID and collect these 30 points
Relay consensus 20 Graduated, and never zero: 1 relay → 5, 23 → 12, 4+ → 20
Federation trust 20 author.did appears in the user's federated DID list
Provenance 15 10 for a 3-part semver version, 5 for a non-empty repo_url. Nothing counts published versions — the old "shows maintenance" reading was wrong
Security compliance 15 15 when validate_manifest() returns no issues, 5 when it returns 12, 0 otherwise

Because "DID present" needs no key material, an unsigned manifest with a plausible-looking DID string and a pinned image already scores 30 + 5 + 10 + 5 + 15 = 65 — "Community" tier. Read the tiers below with that in mind until the signature layer lands.

Trust Tiers

Score Tier UI Treatment
80-100 Verified Green badge, install with one click
50-79 Community Yellow badge, install with confirmation
20-49 Unverified Orange badge, install with warning dialog
0-19 Untrusted Red badge, requires explicit security override

Federation-Based Trust

When a developer's DID appears in the user's federation network (trusted peer), the app automatically receives +20 trust points. This creates organic trust propagation: if you trust a node operator, you're more likely to trust their published apps.

ADR: Nostr Relays over Centralized Registry

Decision: Use Nostr relays as the app discovery layer instead of a centralized registry.

Context: A centralized app store contradicts Archipelago's sovereignty principles. Nostr relays provide censorship-resistant, decentralized event distribution.

Consequences:

  • (+) No single point of failure for app discovery
  • (+) Developers publish without permission or review gates
  • (+) Multiple relay sources increase availability
  • (+) Leverages existing Nostr infrastructure and key management
  • (-) No global content moderation (each node decides trust locally)
  • (-) Spam is possible (mitigated by DID verification and trust scoring)
  • (-) Relay availability varies (mitigated by querying multiple relays)

Signing Protocol

Manifest Signing (DID Layer) — (not implemented)

The steps below are the intended design. Nothing in the codebase produces or checks a did_signature today; marketplace.publish emits the manifest inside a Nostr event and relies on the event's own Schnorr signature.

1. Serialize manifest to canonical JSON (sorted keys, no whitespace)
2. Compute: manifest_hash = SHA-256(canonical_json)
3. Sign: did_signature = Ed25519_Sign(did_private_key, manifest_hash)
4. Attach to manifest:
   {
     "signatures": {
       "manifest_hash": "sha256:<hex>",
       "did_signature": "<base64>"
     }
   }

Event Signing (Nostr Layer)

Standard NIP-01 Schnorr signature over the event ID (hash of serialized event fields). This is handled by the Nostr client library.

Verification Flow

Receiving Node:
  1. Verify Nostr event signature (NIP-01)       → Proves event authenticity   [IMPLEMENTED]
  2. Extract manifest JSON from event content                                  [IMPLEMENTED]
  3. Compute SHA-256 of manifest content                                       [NOT IMPLEMENTED]
  4. Compare with manifest.signatures.manifest_hash → content integrity        [NOT IMPLEMENTED]
  5. Resolve DID document for manifest.author.did                              [NOT IMPLEMENTED]
  6. Verify did_signature with DID public key       → developer identity       [NOT IMPLEMENTED]
  7. Check container.image tag is pinned (not :latest)                         [ADVISORY ONLY]
  8. Validate security fields meet minimums                                    [ADVISORY ONLY]

Steps 36 are the unimplemented DID layer from the warning at the top of this document. Steps 78 run, but validate_manifest() returns a list of issues that feed the trust score — they do not block discovery or installation.

RPC Endpoints

Marketplace Discovery

Method Description Auth
marketplace.discover Query relays for app manifests, verify, score, return sorted Local
marketplace.publish Publish an app manifest to configured relays Local
marketplace.get-manifest Get full manifest for a specific app by ID Local
marketplace.verify Verify a manifest's signatures and security compliance Local

Manifest Management

Method Description Auth
marketplace.list-published List manifests published by this node Local

Purchases

Method Description Auth
marketplace.create-invoice Create a Lightning BOLT11 invoice for a paid app Local
marketplace.check-payment Poll whether an invoice has settled Local

marketplace.unpublish was specified here but never implemented — the string appears nowhere in the codebase, and there is no dispatcher entry. NIP-33 replaceable events mean an unpublish would have to be a tombstone/replacement rather than a delete, which is presumably why it stalled.

Security Requirements

Container Security Enforcement

validate_manifest() checks the following and returns them as a list of issues. These are score inputs, not gates — a manifest that fails all of them is still discoverable and installable, it just scores 0 on the security factor:

  1. No latest tag: Image must use a specific version tag
  2. Read-only root: readonly_root should be true
  3. No root: run_as_user must be ≥ 1000 (the code's bound; the example manifest above uses exactly 1000)
  4. No new privileges: no_new_privileges should be true

Items previously listed here — a capability allow-list, a host-networking ban, and system-path mount restrictions — are not part of marketplace validation. Those rules exist, but they live in the runtime manifest parser (core/container/src/manifest.rs, see app-manifest-spec.md) and apply to apps/*/manifest.yml, which is a different schema from the marketplace manifest. Closing that gap is part of the pre-third-party-publishing work.

Image Verification

  • Container images are pulled from registries, never transferred between nodes
  • Future: Cosign signature verification for container images (leverages core/security/)
  • Image digest pinning recommended for production apps

UI: Community Marketplace Tab

Route

Extends existing /dashboard/marketplace page.

Layout

Two tabs at the top of Marketplace.vue:

  1. Curated (existing): Built-in apps maintained by Archipelago team
  2. Community (new): Apps discovered from Nostr relays

Community Tab Components

  1. App Grid: Same card layout as curated tab, with trust score badge
  2. Search & Filter: Category filter + text search across community apps
  3. Trust Indicators: Color-coded badges (Verified/Community/Unverified/Untrusted)
  4. App Detail: Shows full manifest, developer DID, relay sources, version history
  5. Install Flow: Trust-level-dependent confirmation (one-click for Verified, warning for Untrusted)

Publishing UI

Accessible from Settings or a "Developer" section:

  1. Select a local app container to publish
  2. Fill in manifest metadata (description, category, icon)
  3. Review security compliance
  4. Sign and publish to relays
  5. View published manifests and their discovery status

Data Storage

/var/lib/archipelago/marketplace/
  ├── cache/
  │   └── manifests.json      # Cached discovered manifests, trust scores included
  └── published/
      └── <app-id>.json       # Manifests published by this node

The earlier version of this tree also listed cache/trust-scores.json and config.json. Neither is written: scores live on the cached entries themselves (MarketplaceCache), and there is no marketplace preferences file.

Implementation Notes

Relay Query Strategy

  1. Query all enabled relays in parallel (from nostr_relays.rs config), with a 10s connect timeout and a 20s fetch timeout per relay
  2. Deduplicate manifests by app_id + version
  3. If the same manifest is found on multiple relays, boost trust score
  4. Write results to cache/manifests.json

Items 45 of the original design — a 15-minute cache TTL and a 30-minute background refresh — are not implemented. The cache has no expiry and nothing refreshes it on a timer; it is rewritten whenever marketplace.discover runs.

Version Comparison

  • Use semantic versioning for all version comparisons
  • When multiple versions exist for the same app_id, show the latest
  • Keep version history available in app detail view
  • Flag apps with versions older than 6 months as potentially unmaintained