Skip to content

Marketplace

The FreeSDN Marketplace is a curated directory of installable plugins served from a remote registry. It provides browsing, version history, ratings, reviews, and one-click install - all gated behind Ed25519 catalog signing and SHA-256 download verification.

This page covers how browsing and installing works, the security model behind catalog signing, how to sign your own catalog for a private or third-party registry, and what the review system does.


How the Marketplace fits into the plugin system

Section titled “How the Marketplace fits into the plugin system”

The Marketplace is one path to getting a plugin onto your instance. The other path is direct ZIP upload (POST /api/v1/plugins/install). Both end up calling the same PluginLoader.install_plugin pipeline - the Marketplace simply adds a catalog layer on top: it knows where to download the ZIP, what version history exists, and what SHA-256 hash to expect.

The API lives at /api/v1/marketplace/plugins. The Swagger reference is at /api/v1/docs (unavailable in production; accessible in non-production environments when ENABLE_DOCS=true).


These endpoints are read-only and require no authentication - they are effectively public. No login or session is required to browse the catalog:

Method Path Purpose
GET /api/v1/marketplace/plugins Paginated browse - filter by category, sort by downloads / rating / newest / name
GET /api/v1/marketplace/plugins/featured Up to six featured plugins
GET /api/v1/marketplace/plugins/categories Category list with counts
GET /api/v1/marketplace/plugins/{slug} Plugin detail by slug
GET /api/v1/marketplace/plugins/{slug}/versions Full version history
GET /api/v1/marketplace/plugins/{slug}/reviews Reviews, paginated (up to 50 per page)

GET /api/v1/marketplace/plugins accepts:

Parameter Values Notes
q string (≤ 128 chars) Substring search on name and description
category string (≤ 64 chars) Filter by category slug
sort downloads, rating, newest, name Default: downloads
page integer ≥ 1 Pagination cursor
per_page 1-100 Items per page

Available categories: monitoring, security, automation, integration, analytics, device, reporting.

The remote registry feed can set: name, description, author, version, download URL, checksum_sha256, tags, screenshots, icon, banner, and minimum version requirements.

The registry feed cannot set - and these values are ignored from the feed:

  • is_verified (whether FreeSDN has verified the publisher)
  • is_featured
  • status (draft / published / suspended / deprecated)
  • download_count
  • Database IDs or timestamps

This prevents a compromised or third-party registry from self-declaring “Verified by FreeSDN” status on a plugin. Those fields are controlled exclusively by your local database.


Before you can browse, your instance needs catalog data. Sync pulls from the remote registry and upserts the catalog into your local database:

POST /api/v1/marketplace/plugins/sync

Requires: super_admin.

Sync enforces the following:

  1. HTTPS only; no redirects; DNS-resolved to a public IP (DNS-rebind-safe); streamed response capped at 5 MB.
  2. JSON parsed - 502 on malformed JSON.
  3. Catalog signature verified (see the section below).
  4. Only the allowlisted fields above are written to the database.

If the remote registry is unreachable, FreeSDN falls back to a first-party seed file at backend/app/data/marketplace_seed.json. This file is trusted by provenance (it ships with the application source), not by signature check.

The seed is generated from the published catalog by registry/build.py, so it is the same list of plugins with the same checksums. An air-gapped instance therefore gets a real, current index and only the download itself needs a network, much like a package manager’s cached index. When the instance later reaches the registry, the online sync updates the same rows rather than colliding with them.


A sync also de-lists anything your instance knows about that the registry no longer carries. The response names them:

{
"synced": 9,
"withdrawn": ["notify-hub", "ai-ops-tools"],
"message": "Synced 9 plugins from registry (0 skipped). Withdrawn (no longer in the registry): ai-ops-tools, notify-hub"
}

A withdrawn plugin disappears from browse and can no longer be installed - that request answers 410 Gone with the reason, rather than a 404 that reads like a typo.

Three things it deliberately does not do:

  • It does not delete the record. Reviews, download counts and history are kept, and a plugin the registry lists again is published again automatically.
  • It does not touch an installed copy. The registry decides what is on offer; you decide what runs. An upstream index change never stops a plugin you are already running, though it will stop receiving updates.
  • It never de-lists from the bundled seed. Withdrawal is only ever driven by the remote, signature-verified catalog. The seed is a fallback for an unreachable registry, not an authority on what has been withdrawn - otherwise one failed fetch would de-list a private registry that was merely down.

This matters because withdrawal is the only lever a registry operator has over a plugin that turns out to be harmful. Before this, a plugin dropped from the catalog stayed listed and installable for ever on every instance that had synced once.


This is the most security-critical part of the Marketplace.

The catalog is signed with an Ed25519 key pair. The publisher signs a canonicalized JSON representation of the catalog (keys sorted, compact, signature field removed). The signature is a detached hex string delivered alongside the catalog JSON.

During sync, FreeSDN recanonicalizes the fetched catalog and verifies the signature against the pinned public key before touching the database.

MARKETPLACE_PUBLISHER_PUBLIC_KEY set? MARKETPLACE_ALLOW_UNSIGNED set? Behavior
Yes - Signature required and verified against pinned key. Mismatch → 403. This is what the shipped compose does.
No No Sync refused - 403. This is the code default for a bare checkout with no compose.
No Yes (1, true, or yes) Unsigned catalog accepted with a loud warning logged.

The shipped docker-compose.yml already pins the public key for the official registry at https://registry.freesdn.org, so a standard deployment verifies signatures out of the box and needs no action here. The key is also printed on the registry’s publishing page.

To pin a different key, for your own registry or a mirror, set the MARKETPLACE_PUBLISHER_PUBLIC_KEY environment variable to that Ed25519 public key in hex:

# .env.pro / .env.max
MARKETPLACE_PUBLISHER_PUBLIC_KEY=<hex-encoded-ed25519-public-key>

You cannot pin a key at runtime - it must be set before the process starts, because the variable is read at sync time.


POST /api/v1/marketplace/plugins/{slug}/install

Requires: super_admin.

What happens when you trigger a marketplace install:

  1. The catalog entry for {slug} is looked up in your local database (sync first if it is missing).
  2. The download_url from the catalog is fetched over HTTPS with DNS pinning, no redirects, and a 50 MB streaming cap.
  3. The downloaded bytes are SHA-256 hashed and compared against checksum_sha256 from the catalog entry. Mismatch → install aborted.
  4. The verified archive is passed to PluginLoader.install_plugin - the same ZIP pipeline used for direct uploads: zip-bomb protection (200 MB uncompressed cap), zip-slip rejection, manifest parse, optional hash-pinned dependency install, class load, on_install hook.
  5. The plugin record is persisted and the on_install hook is called.
  6. The plugin is started for every active organization and its routes are mounted, exactly as a direct ZIP upload does. It is usable the moment the call returns.
  7. An audit row is written (tag: ["plugin","supply-chain","marketplace"]).
  8. The catalog’s download_count is incremented.
  9. HTTP 201, carrying restart_required and, when true, a note saying why.

POST /api/v1/marketplace/plugins/{slug}/upgrade

Requires: super_admin.

The Marketplace card shows Update to vX instead of Installed whenever the catalog carries a version newer than the one you have. The plugin detail page shows the same control.

The sequence mirrors install, with two differences that matter:

  1. The catalog entry is resolved. A withdrawn plugin answers 410 - withdrawal stops updates as well as installs, or a plugin pulled for being harmful would keep being delivered to everyone who already had it.

  2. The installed record is checked. Not installed → 404, “install it first”.

  3. Versions are compared numerically, using the same parser the backend uses for min_core_version. Anything that is not forward motion is refused with 409:

    Situation Response
    Same version already installed 409 “already on 1.2.0. Sync the registry if you expected a newer version.”
    Installed version is newer 409 “refusing to roll back; uninstall and install if that is really what you want.”

    Both arrive by pressing a button labelled update, so silently reinstalling the same version or silently rolling back would be the wrong answer to what was asked. A version string the parser cannot judge (nightly, say) does not block the update - the guard only fires when both sides parsed.

  4. The archive is downloaded and its SHA-256 checked against the catalog, exactly as install does - the same helper, so the two cannot drift.

  5. The plugin is stopped everywhere before its files are replaced. Its per-organization runtimes hold connections, event subscriptions and scheduler claims belonging to the old code.

  6. upgrade_plugin runs, on_upgrade is called with the previous version, and the plugin is started again with its routes remounted.

  7. An audit row records previous_version alongside the new one.

  8. 200 with previous_version, version, and restart_required.

Settings and schedules survive an update - they are keyed by plugin, not by version.


Use this when you operate a private registry or want to publish plugins outside the official FreeSDN registry. The signing tool ships with the backend:

backend/scripts/sign_marketplace_catalog.py
Terminal window
# Requires: Python ≥ 3.11 with the cryptography package
python scripts/sign_marketplace_catalog.py keygen
# Outputs PRIVATE_KEY_HEX and PUBLIC_KEY_HEX to stdout.
# Copy PUBLIC_KEY_HEX into MARKETPLACE_PUBLISHER_PUBLIC_KEY.
# Store PRIVATE_KEY_HEX offline - never commit it.

Store the private key outside your repository and outside your deployment containers. The hex public key goes into MARKETPLACE_PUBLISHER_PUBLIC_KEY on every FreeSDN instance that should trust your catalog.

Your catalog file must be valid JSON matching the expected schema (an array of plugin objects). To sign:

Terminal window
python scripts/sign_marketplace_catalog.py sign \
--key <PRIVATE_KEY_HEX> \
--in plugins.json \
--out plugins-signed.json

The tool:

  1. Reads your catalog JSON.
  2. Removes any existing signature field.
  3. Produces a canonicalized representation (keys sorted, compact, no whitespace).
  4. Signs that canonical bytes with the Ed25519 private key.
  5. Writes a new JSON file identical to the input but with a signature field added at the top level (hex-encoded signature).
MARKETPLACE_REGISTRY_URL=https://your-registry.example.com/plugins.json
MARKETPLACE_PUBLISHER_PUBLIC_KEY=<your-public-key-hex>

Both variables are read at sync time.

Every time you add, remove, or update a plugin entry in your catalog file, you must re-sign it. Serving a stale or unsigned catalog causes sync to fail with 403 on any instance with key pinning enabled.


Any active user can submit one review per plugin. Super_admin authority is not required.

POST /api/v1/marketplace/plugins/{slug}/reviews

Request body:

Field Type Constraints
rating integer 1-5, required
title string ≤ 200 characters, optional
body string ≤ 4000 characters, optional

Constraints:

  • One review per user per plugin. A second submission returns 409.
  • After each new review the plugin’s aggregate rating and rating_count are recomputed.
  • Reviews are public reads - no authentication is required to list them via GET /api/v1/marketplace/plugins/{slug}/reviews.

Variable Default Purpose
MARKETPLACE_REGISTRY_URL https://registry.freesdn.org/plugins.json Remote catalog URL for sync
MARKETPLACE_PUBLISHER_PUBLIC_KEY (empty) Hex Ed25519 public key to require for signature verification
MARKETPLACE_ALLOW_UNSIGNED (unset / false) Set 1, true, or yes to accept unsigned catalogs - logs a loud warning

Sync returns 403 - “Catalog signature verification failed”

You have MARKETPLACE_PUBLISHER_PUBLIC_KEY set and the fetched catalog’s signature does not match. Causes:

  • The catalog file was updated but not re-signed.
  • You are pointing at the wrong registry URL.
  • Your key was rotated but the instance env was not updated.

Check that MARKETPLACE_REGISTRY_URL resolves to the intended host, that your public key hex matches the signing private key, and that the catalog was signed after its last edit.

Sync returns 403 - “Unsigned catalogs not permitted”

You have no key pinned and MARKETPLACE_ALLOW_UNSIGNED is not set. Either pin a key or explicitly opt in to unsigned operation.

Sync returns 502

The remote URL returned malformed JSON (invalid UTF-8 or a JSON parse error). Check that MARKETPLACE_REGISTRY_URL points to a valid JSON catalog. The seed fallback does not apply in this case - the 502 is returned immediately.

Sync succeeds but shows only first-party plugins (seed data)

The remote registry was unreachable (network error, DNS failure, or timeout). FreeSDN fell back to the bundled first-party seed file at backend/app/data/marketplace_seed.json. Only first-party plugins are available until the registry is reachable again. If no seed file is present, the response is 503 instead.

Install returns a checksum mismatch error

The downloaded archive’s SHA-256 does not match the catalog entry. This is either a corrupted download or a catalog / archive out of sync. Do not retry without investigating - a mismatch is a supply-chain signal.


All product names, logos, and brands are property of their respective owners. FreeSDN is an independent project and is not affiliated with or endorsed by the vendors it integrates with. See Trademarks.