Publishing to the marketplace
How a bundle, a sha256 and a catalog entry fit together.
A dev3d host installs a plugin by fetching a catalog document, choosing an entry, downloading the bundle that entry names, and verifying its sha256 before unpacking a single byte. That is the entire contract. Any static host that can serve the JSON is a marketplace.
The catalog document
The shape is PluginCatalog in the core package, and the field names matter because the host parses them directly. Three fields at the top and six per entry:
| Field | Required | Meaning |
|---|---|---|
version | yes | The catalog format version, independent of the plugin API version. This is the literal 1. It is not read leniently: a catalog is parsed by hand, and a wrong shape throws. |
name | yes | The marketplace's display name. Falls back to the URL's hostname when absent. |
homepage | no | A link for the console to show beside the marketplace. |
plugins | yes | The array of entries. A catalog with no plugins array is rejected outright with "the catalog has no "plugins" array." |
| Entry field | Required | Meaning |
|---|---|---|
manifest | yes | A complete plugin manifest, validated by the same validator that validates a plugin on disk. An invalid manifest in the catalog rejects the whole catalog, naming the index and the clauses. |
downloadUrl | yes | Absolute, or relative to the catalog URL — the host resolves it against the catalog's own URL, so relative paths are genuinely relative to where the document was served. |
sha256 | no, but publish it | Lowercase hex, 64 characters. A value that does not match that pattern is silently dropped, which turns verification off for that entry. When present and wrong, the install fails. |
tags | no | Free-form strings a marketplace can filter on. |
sizeBytes | no | Total bytes, so a console can show the size before downloading. |
readme | no | Whatever the marketplace wants to say about it. |
The manifest inside an entry is the authority. The host matches on manifest.id, compares manifest.version to decide whether an update is on offer, and validates apiVersion before it will consider the entry at all.
What this site serves
This site is a marketplace in exactly that sense. /marketplace/catalog.json is generated on every request from published plugin listings and their published, non-yanked versions — so publishing in the console is all it takes for an entry to appear. The response is cached for a minute with a long stale-while-revalidate window, and carries an open CORS header, because a marketplace that is briefly stale is harmless and a marketplace that is down is not. If generation fails, the endpoint answers 503 with an empty plugin list and an error field rather than a malformed document.
Bundles are served from /marketplace/bundles/<id>-<version>.tar.gz. The filename is treated as untrusted: it is resolved through the database before anything touches the filesystem, so an unknown name is a 404 even if a stray file exists, and a path traversal fails closed. Downloads are attributed — the dev3d version and operating system are parsed out of the user agent, and the IP is only ever stored as a rotated hash, enough to count downloads and not enough to identify anyone. Each 200 response is application/gzip with a content length, so a host can show progress. A published version whose bytes have gone missing answers 410 with "Bundle unavailable".
To point a running office at it, register the source. The console has a field for it, and the HTTP API takes the same two values:
curl -X POST localhost:8787/api/plugins/sources \
-H 'content-type: application/json' \
-d '{"label":"dev3d.net","url":"https://dev3d.net/marketplace/catalog.json"}'
A source URL must start with http:// or https:// — anything else is refused with that sentence as the error. A blank label falls back to the URL's hostname, and registering the same URL twice is refused as "That marketplace is already registered." Once registered, the console can ask every marketplace what it is offering; a source that cannot be reached is recorded with its last error and skipped, so one dead marketplace never stops the others being checked and is never mistaken for "nothing newer exists". The catalog fetch times out after 15 seconds and a bundle download after 30, because a marketplace problem should be a visible failure rather than a hung console.
What a bundle is, and how the host treats it
A bundle is a .tar.gz of a plugin directory. There is no zip support and no other archive format. The host's reader is hand-written against POSIX ustar over Node's zlib rather than pulling in a dependency, and it is written as an untrusted input parser: an archive that arrives from a marketplace is attacker-controlled data, and the reader's job is to refuse rather than to salvage.
What that means in practice, with the real refusals:
| The bundle contains | What the host does |
|---|---|
| An absolute path, including a Windows drive-letter form | Refused by name |
A .. segment, or any path that resolves outside the destination | Refused by name |
| A symlink or hard link | Refused: bundles may not contain links |
| A device node, fifo, or any type other than file and directory | Refused as an unsupported type |
| A GNU long-name or long-link extension | Refused, with the advice to repack with ustar names |
| A base-256 size field, or an octal field that will not parse | Refused |
| An entry whose data runs past the end of the archive | Refused as truncated |
| Total unpacked file bytes over 32 MB | Refused: expands past the limit |
| More than 2048 files | Refused: too many files |
| No files at all | Refused: the bundle contained no files |
| Not valid gzip | Refused with the decompression error |
Path normalisation is POSIX on purpose, not the platform's path library. An archive path is always forward-slashed, and normalising with the host platform's rules would rewrite /etc/passwd into a drive-relative path on Windows — quietly turning an absolute path that must be refused into one that looks relative.
Both size caps are on the unpacked side as well as the compressed side, because a 4 KB archive can otherwise expand into gigabytes. Installation extracts into a staging directory under the install root and only moves the result into place once the manifest has been read, validated, and confirmed to declare the id that was requested — so a failed or partial extraction never leaves a half-installed plugin behind.
The manifest may sit at the archive root or inside exactly one wrapping directory, since tar czf bundle.tar.gz my-plugin/ produces the second shape. Two wrapping directories are ambiguous and refused. The archive this site builds packs the directory's contents rather than the directory itself, so it satisfies the first shape and needs no unwrapping at all; it also filters out node_modules, .git and .DS_Store, forces portable headers, and drops timestamps so that two builds of the same source hash the same.
Installation is off by default. The host refuses to install any bundle until DEV3D_ALLOW_PLUGIN_INSTALL is the literal string true. The default refusal reads: "Installing plugins is disabled. Set DEV3D_ALLOW_PLUGIN_INSTALL=true to allow running code downloaded from a marketplace." That is deliberate — it is the difference between "this installation reads a catalog" and "this installation will execute code from the internet" — and it is also why the console shows the flag's state rather than just hiding the install button.
Publishing here: the flow
Publishing is self-service. Any signed-in account can claim a listing and ship a version; what stands between a submission and the catalog is a review queue, not a permission on your account. An administrator can also create and upload from the admin console, which is the path for a first-party plugin.
- Claim the listing. In Publishing. The plugin id is the identity a dev3d host matches on, so it is validated on creation — lowercase reverse-dns with at least one dot, the same rule as the manifest — and it cannot be changed afterwards, because changing it would orphan every installed copy. The listing starts as a draft.
- Ship a version. Either upload the
.tar.gz, or connect a GitHub repository and let its tagged releases become versions. Both routes end in the same check: the archive is inspected before a byte is copied into storage, its manifest is located and validated, and its id is compared with the listing. A mismatch is refused with "The bundle's manifest declares "x", but this listing is "y"." If the version already exists you are told so and offered a replace option; the version number itself comes fromplugin.json, not from the form. On success, the site reports the file count, the size and the first 16 characters of the sha256 — and the bytes it stored are the exact bytes it hashed, so the digest the catalog advertises is the digest a host will verify. - Review. The version enters the review queue and is not installable until a person approves it. It is hidden from the catalog and from its own bundle URL, so a guessed filename is not a way around the queue. Approving the first version of a draft listing publishes the listing. A publisher an administrator has marked trusted skips this step, and only an administrator can grant that.
The listing's own metadata — summary, description, author, licence, category, tags, homepage, repository, icon, accent colour — is editorial and lives in the database, separate from the manifest. But the fields that matter to a host are refreshed from the manifest on every upload: kind, apiVersion, permissions and the contribution counts. The manifest is the authority on what a plugin is; the listing is the authority on how it is presented.
Two operational details are worth knowing. A published version can be withdrawn: an approved version is yanked, which removes it from the catalog and stops it being served without deleting the row the download history points at. Withdrawing the only approved version of a published listing is refused, because it would leave a published listing with nothing to install. And a Verify action in the console re-reads the stored bundle and re-computes its hash against the recorded one, reporting either a match and the byte count, or a mismatch with both digests. That is the check to run when you suspect storage rather than a manifest.
The step-by-step publisher's view — the workspace, the GitHub connection, the review queue, and publishing from a pipeline with an API token — is in Listing a plugin and Publishing from CI.
This site's validation is a mirror of the host's, not the authority. The marketplace checks the same rules a host does — id shape, apiVersion major, a relative entry, a readable archive — so that you cannot publish something that will fail to install. Where the two ever disagree, the host that installs is the one that decides, and its error is the one your user will see.
The upgrade rules
Upgrades are deliberately narrow. Four rules, all of them enforced with a specific message:
- Forward only. An upgrade whose version is not newer than what is installed is refused: "x is already at 1.2.0; the marketplace offers 1.2.0." The comparison is on three numbers plus a pre-release tag, with a pre-release counting as older than its release.
- Never over a bundled plugin. A plugin that shipped in the plugins directory cannot be replaced by a marketplace bundle: "x ships with the office; update it by updating dev3d." The reasoning is that replacing it would leave the installed files and the loaded set disagreeing about what version ships there.
dev3d.cost-guard,dev3d.local-coderanddev3d.office-echoall fall under this rule. - No downgrade. There is no mechanism for it, and no message for it, because the version comparison refuses anything that is not strictly newer. Rolling back a bad plugin means removing it and installing the older version as a fresh install, which is a different operation with different consequences.
- No pinning. A record can carry an
updatedescribing what a marketplace is offering, but nothing records "stay on this version". You decline an upgrade by not running it; the console will keep offering it, because offering is all it does.
Do not assume strict semver. A manifest's version is only shape-checked at validation time against a three-number pattern with an optional pre-release tag; nothing compares versions there. The one comparison in the host is deliberately not a semver implementation — a missing or unparseable component counts as zero, so 1.2 and 1.2.0 compare equal rather than one being invalid. The only question ever asked of it is "is this newer", which means build metadata and the finer points of precedence are simply not part of the contract.
The swap itself is ordered carefully. On an upgrade the old plugin is unloaded before the new directory is moved into place, because its tools and event subscriptions belong to the code being replaced — leaving them registered would let an in-flight run keep calling into the version that has just been deleted. Only then is the target directory removed, the staged root renamed into position, and the new directory loaded. The pending update marker for that plugin is cleared on success.
What to check if it did not work
| Symptom | What it really means | What to do |
|---|---|---|
| "checksum mismatch: the marketplace published a1b2c3d4e5f6a7b8… but the bundle hashes to 9f8e7d6c5b4a3210…" | The bytes a host downloaded are not the bytes that were hashed at upload. Either the stored bundle was replaced or edited outside the console, or something is rewriting it in transit. | Run Verify on that version in the console. If the stored file no longer matches, re-upload the bundle; the hash is recomputed from the new bytes. If it still matches in the console but a host disagrees, the host is being served something else — check any proxy or CDN in front of the site. |
An install fails with an apiVersion clause: "targets API "2", but this host implements "1"." |
The plugin's major does not match the host's. This is the reject path: nothing about the plugin loaded. | Either fix apiVersion in the manifest and upload a new version, or update the host if it is the older of the two. The comparison is on the major only, so 1.5 is fine. |
| A host refuses with "the bundle has no plugin.json at its root or in a single wrapping directory." | The bundle nests one level too deep — usually tar czf x.tar.gz ./my-plugin/src/, or a directory that itself contains the plugin directory. Only the root and exactly one wrapping directory are searched. |
Repack from inside the plugin directory: tar czf x.tar.gz -C my-plugin ., or pack my-plugin/ as the single wrapper. Either shape installs; two wrappers never will. |
The install button or the whole install path is refused, with a message about DEV3D_ALLOW_PLUGIN_INSTALL. |
The install gate is off, which is the default. This is not a bug or a misconfiguration — it is the shipping default. | Set DEV3D_ALLOW_PLUGIN_INSTALL=true in the environment of the dev3d server. It is read from the environment for the life of the process: unlike the engine settings, it is not copied into saved settings on first boot, so enable it where the process is launched and restart. |
| "could not reach the marketplace: ..." or "the marketplace returned HTTP 404." | The catalog URL did not resolve to a JSON document. A host fetches the URL you registered, not a guessed path. | Fetch the URL yourself. It must end at the catalog document — on this site, /marketplace/catalog.json — and answer 200 with JSON. |
| The catalog is rejected with "plugins[3].manifest is invalid — …". | One entry's manifest failed validation, and the host refuses the whole document rather than skipping it. A marketplace that publishes one broken entry is unreadable to every host. | Fix that entry. The clauses after the dash name the field and the rule, exactly as a manifest on disk would. |
| A plugin is not offered as an update, or a published listing has no catalog entry at all. | Offers are only computed for plugins that are already installed, and only for a strictly newer version. Catalog entries come from published listings with at least one published, non-yanked version. | Install it first, publish the version before the listing, and check the version string really is newer numerically rather than alphabetically. |
| An upload is refused because the manifest id does not match the listing. | You uploaded a bundle for a different plugin. The site checks the id inside the archive against the listing it is attached to. | Upload the right bundle, or create the listing for the id the manifest actually declares. A listing's id cannot be edited after creation. |
| "...already at 1.0.0; the marketplace offers 1.0.0." when you expected an upgrade. | You forgot to bump version in the manifest before rebuilding the bundle, and the upload either replaced the identical version or was refused as a duplicate. |
Bump the version in plugin.json, rebuild the bundle, and upload it as a new version rather than replacing the existing one. |
| An upgrade was offered for a plugin that ships with the office. | The console can show an offer for any installed plugin, but the upgrade itself is refused for a bundled source. The offer is not a promise. | Update dev3d instead. A bundled plugin's version is whatever the checkout says it is. |
| A bundle is refused as a link, an unsupported type or a GNU long name, or for being over the size limit. | The archive was produced by a tool that emits GNU extensions or preserved symlinks; or the compressed file or its unpacked contents exceed 32 MB, or there are more than 2048 entries. | Repack portably with no symlinks and short paths, and trim the payload. The site inspects uploads the same way and will tell you the same thing. |
| A download answers 410 with "Bundle unavailable". | The database row exists but the bytes are gone from storage — a restored database against an empty storage directory is the usual cause. | Re-upload the bundle for that version. The console's Verify action reports this as a missing file before a host ever sees it. |
Where to go next
Linked from
Did this page answer your question?