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).
Browsing the catalog
Section titled “Browsing the catalog”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) |
Browse parameters
Section titled “Browse parameters”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.
What the catalog cannot tell you
Section titled “What the catalog cannot tell you”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_featuredstatus(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.
Syncing the catalog
Section titled “Syncing the catalog”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/syncRequires: super_admin.
Sync enforces the following:
- HTTPS only; no redirects; DNS-resolved to a public IP (DNS-rebind-safe); streamed response capped at 5 MB.
- JSON parsed - 502 on malformed JSON.
- Catalog signature verified (see the section below).
- Only the allowlisted fields above are written to the database.
First-party seed data
Section titled “First-party seed data”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.
When a plugin leaves the catalog
Section titled “When a plugin leaves the catalog”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.
Catalog signature verification
Section titled “Catalog signature verification”This is the most security-critical part of the Marketplace.
How signing works
Section titled “How signing works”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.
The three signing postures
Section titled “The three signing postures”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. |
Setting up key pinning
Section titled “Setting up key pinning”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.maxMARKETPLACE_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.
Installing a plugin from the Marketplace
Section titled “Installing a plugin from the Marketplace”POST /api/v1/marketplace/plugins/{slug}/installRequires: super_admin.
What happens when you trigger a marketplace install:
- The catalog entry for
{slug}is looked up in your local database (sync first if it is missing). - The
download_urlfrom the catalog is fetched over HTTPS with DNS pinning, no redirects, and a 50 MB streaming cap. - The downloaded bytes are SHA-256 hashed and compared against
checksum_sha256from the catalog entry. Mismatch → install aborted. - 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_installhook. - The plugin record is persisted and the
on_installhook is called. - 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.
- An audit row is written (tag:
["plugin","supply-chain","marketplace"]). - The catalog’s
download_countis incremented. - HTTP 201, carrying
restart_requiredand, when true, anotesaying why.
Updating an installed plugin
Section titled “Updating an installed plugin”POST /api/v1/marketplace/plugins/{slug}/upgradeRequires: 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:
-
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.
-
The installed record is checked. Not installed → 404, “install it first”.
-
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. -
The archive is downloaded and its SHA-256 checked against the catalog, exactly as install does - the same helper, so the two cannot drift.
-
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.
-
upgrade_pluginruns,on_upgradeis called with the previous version, and the plugin is started again with its routes remounted. -
An audit row records
previous_versionalongside the new one. -
200withprevious_version,version, andrestart_required.
Settings and schedules survive an update - they are keyed by plugin, not by version.
Signing your own catalog
Section titled “Signing your own catalog”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.pyGenerate a key pair
Section titled “Generate a key pair”# Requires: Python ≥ 3.11 with the cryptography packagepython 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.
Sign a catalog file
Section titled “Sign a catalog file”Your catalog file must be valid JSON matching the expected schema (an array of plugin objects). To sign:
python scripts/sign_marketplace_catalog.py sign \ --key <PRIVATE_KEY_HEX> \ --in plugins.json \ --out plugins-signed.jsonThe tool:
- Reads your catalog JSON.
- Removes any existing
signaturefield. - Produces a canonicalized representation (keys sorted, compact, no whitespace).
- Signs that canonical bytes with the Ed25519 private key.
- Writes a new JSON file identical to the input but with a
signaturefield added at the top level (hex-encoded signature).
Point instances at your registry
Section titled “Point instances at your registry”MARKETPLACE_REGISTRY_URL=https://your-registry.example.com/plugins.jsonMARKETPLACE_PUBLISHER_PUBLIC_KEY=<your-public-key-hex>Both variables are read at sync time.
Re-signing after catalog updates
Section titled “Re-signing after catalog updates”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.
Reviews and ratings
Section titled “Reviews and ratings”Any active user can submit one review per plugin. Super_admin authority is not required.
POST /api/v1/marketplace/plugins/{slug}/reviewsRequest 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
ratingandrating_countare recomputed. - Reviews are public reads - no authentication is required to list them via
GET /api/v1/marketplace/plugins/{slug}/reviews.
Environment variable reference
Section titled “Environment variable reference”| 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 |
Troubleshooting sync
Section titled “Troubleshooting sync”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.
Next steps
Section titled “Next steps”- Plugins Overview - the two-tier trust model and what SDK plugins can and cannot do
- Getting Started - scaffold and locally install your first plugin
- Packaging and Publishing - build a distributable ZIP and prepare a catalog entry
- Manifest Reference - every
plugin.yamlfield includingpython_dependenciespinning rules
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.