The marketplace lifecycle
The whole life of a listing: submission, review, install, update, withdrawal, trust and suspension.
A listing is a thing that moves through states rather than a thing you upload once. This page is the whole of that movement — what has to exist before the first version, how each of the three submission routes is treated, what review actually gates, what an installation sees afterwards, and how a listing is withdrawn, trusted or stopped.
The mechanics of a single publish are already written down: Listing a plugin is the publisher's walkthrough and Publishing from CI is the token-and-pipeline version. What follows assumes you have read one of those and want the rest of the life of the listing — the decisions in between, and the ones that only come up a year later when somebody needs a version to stop being installable.
The states, in one table
| State | What it means | Who can move it there | What a reader sees |
|---|---|---|---|
A listing is draft | The id is claimed and the editorial fields exist, but nothing is installable. | Any signed-in account, by claiming an id in the publisher workspace. | Nothing in the plugin index and no catalog entry. The detail page is reachable by its direct URL, and it lists no installable version. |
A listing is published | At least one approved, un-yanked version exists, so there is something to install. | Automatically, when the first version of a draft is approved; or an administrator publishing it explicitly, which is refused if there is no un-yanked version. | The listing page, an entry in the catalog, and its bundle URLs. |
A listing is archived | Withdrawn. The rows survive so download history and audit entries still resolve; nothing is served. | An administrator with the publish permission, or the console's status control. | Nothing in the catalog and no bundle, because the bundle route requires a published listing. The detail page is still reachable by URL. The plugin id stays claimed, so nobody else can take it. |
A version is pending | Submitted and waiting for a person. | The publisher, by uploading, syncing a release or calling the API without trust. | Nothing — not in the catalog, and not at its own bundle URL either. |
A version is approved | Cleared review. This is the only status the catalog and the bundle route will serve. | An administrator in the review queue, or the submitting account itself when it is trusted. | The version on the listing page, and a downloadable bundle. |
A version is rejected | Turned down with a note the publisher can act on. | An administrator, and the note is required rather than optional. | Nothing public. The publisher sees the note on their own listing. |
A version is yanked | Approved once, withdrawn later. The row stays; the bytes stop being served. | Its publisher, or an administrator. | It disappears from the catalog and its URL answers 404. Nothing recalls a copy already installed. |
The asymmetry to notice in that table is that a listing has an explicit archived state while a version has yanking, and the two solve different problems. Archiving is "this plugin should not be offered any more"; yanking is "this one release was wrong". A listing whose only approved version is yanked simply drops out of the catalog, because the catalog is generated from what is servable rather than from a flag — which is why the code refuses the two operations that would leave a published listing with nothing behind it.
Before you publish anything
Three things have to exist first, and only one of them is technical.
- An account, which somebody may have to invite you to. Self-service sign-up exists and is off by default — the setting is
site.registration_open, false unless an administrator opens it in the console under Registration. On a site with registration closed, an account arrives by invitation. Publishing itself needs no special permission: any signed-in account may claim a listing and ship a version, because the whole premise of a self-service marketplace is that authors maintain their own work. - A plugin id, which is claimed once and never changes. The id is lowercase reverse-dns with at least one dot —
yourname.your-plugin— validated the moment you claim it, and immutable afterwards, because every installation that already has your plugin matches on it. The dot is not decoration: the host namespaces everything a plugin contributes by its id, so an id with no namespace separator would let two plugins collide on a tool name. A claimed id is exclusive: a second account asking for the same one is toldThe plugin id "x" is already taken. Ids are claimed once and cannot be shared. - A manifest that validates, and a decision about code. The one field that shapes everything else is
entry. With it, the plugin is code: the host will import that module into its own process. Without it, the plugin is data, and the worst a hostile one can do is waste console space. Which of the two a listing is, is not something you declare on a form — it is derived from the manifest on every upload, alongsideapiVersion, the declared permissions and the contribution counts. The manifest is the authority on what the plugin is; the listing is the authority on how it is presented. Plugin manifest is the field contract, and choosing between the two kinds is Building a plugin.
One consequence of that third point is worth stating before anything is uploaded: an upload is checked by this site's validator before a byte is stored, and this site's validator is a mirror of the host's rather than the authority. It checks the id shape, the apiVersion major, a relative entry and a readable archive, so that a listing cannot advertise something the host would refuse. Unknown manifest fields are passed through untouched on the way into storage and back out through the catalog, precisely so that a host on a newer API version still receives every field it understands. Where the two ever disagree, the host that installs is the one that decides.
Submitting a version
There are three ways in and four source labels, and the difference between them is not cosmetic — it decides trust, and therefore review.
| Route | Who | Source label | Approved on arrival? |
|---|---|---|---|
| Console upload, by an administrator | An account holding the plugin-write permission, in the admin console. | console | Yes. Uploading there is the review — an administrator's own upload does not queue behind an administrator. |
| Publisher upload, in the workspace | The account that owns the listing. | upload | Only if that account is trusted. Otherwise the version queues. |
| GitHub release import | The account that owns the listing, from its own connected repository. | github | Only if the account is trusted and the connection's auto-approve box is ticked. Trust alone is not enough, and auto-approve alone does nothing. |
| Token publish over the API | A token with the marketplace:publish scope, belonging to the listing's owner. | api | Only if the token's account is trusted. |
All four end in the same function, and that is a deliberate design decision rather than a tidy-up. The sequence is: inspect the archive as hostile input, refuse a manifest whose id disagrees with the listing, copy the exact bytes into storage, hash the copy that will be served, write the row, and only then decide whether the version is approved or queued. Hashing the temporary file instead would advertise a digest of bytes that no longer exist, which is the one failure a host's checksum check cannot survive. Because there is exactly one writer, the digest the catalog advertises is always the digest of the bytes on disk — the property the entire install path rests on.
What each route is actually good at
- The console upload is for a first-party plugin. It is also the only route with no ownership check, because the permission to curate the catalogue is a stronger claim than owning one listing.
- The workspace upload is the shortest path for a hand-built archive. The version number comes from
plugin.json, never from the form, so a bundle whose manifest says1.2.0is1.2.0whatever the file was called. Uploading a version that already exists is refused unless you ask to replace it, and the message is explicit about the two options:Version 1.2.0 already exists. Bump the version in plugin.json, or ask for it to be replaced. - The GitHub route is for maintainers whose release process is already a tag. One repository backs exactly one listing, because the manifest id inside the archive has to match — so two listings cannot both claim the same releases. A sync imports only the newest few releases, because it runs inside a request; releases already imported are never re-done. The tag decides which releases are worth downloading and nothing else: the version that ships is whatever the manifest inside the archive says.
- The API route is for a pipeline. It accepts a multipart
bundle, or JSON with adownloadUrlthe server fetches itself — which is how a GitHub release archive is published without passing through the CI runner — or JSON with inline base64 bytes for a small plugin. Ownership is enforced in the query rather than checked afterwards, so a token cannot publish to a listing it does not own even by naming it correctly. The rate limit is per token rather than per account, so one runaway pipeline cannot exhaust every pipeline the same person runs.
Review
Review is the marketplace's half of its bargain, and the reason a self-service marketplace can be trusted at all: anyone may claim a listing, and nothing reaches a development installation without a person having looked at it. The gate is not a formality layered on top — it is enforced where the bytes are served, not only in the queue. A version is servable when its review status is approved, and that condition is in the catalog query and in the bundle route. That second one matters more than it looks: bundle filenames are predictable, so without the filter a version still sitting in the queue could be downloaded by anyone who guessed its name. The queue would then hide a submission from the catalogue page while leaving it on the internet.
What a reviewer sees is the manifest's own declarations — what the plugin contributes, and which permissions it asks for — because that, not the listing text, is what a host will act on. Approving a version approves that version; it does not quietly approve the next one. Approving the first version of a draft listing also publishes the listing, because a listing with an approved version and a draft status is a state nobody chose — it is what the first submission always produces. An archived listing stays archived: an administrator who withdrew a plugin did so deliberately, and approving a later version is not a request to bring it back.
Rejection requires a reason, and the requirement is in the validation rather than in a guideline: the note must be at least three characters, or the action refuses with Say why it was rejected — the publisher sees this note and needs something to act on. The reasoning is that a rejection nobody can act on produces the same submission again, so a queue that accepted empty rejections would be busier rather than faster. The note is stored on every decision including approvals, and the publisher reads it on their own listing page next to the version's status.
Only trust, or an administrator, bypasses this. An administrator's console upload is approved on arrival. A publisher's is approved on arrival only if an administrator has marked that account trusted — and for a GitHub import, only if the connection's own auto-approve box is also ticked. An administrator can also publish a listing directly, which is the path for a first-party plugin, and which is refused if the listing has no un-yanked version to point the catalog at.
Published and installable
Publishing produces two public artefacts, and they are generated rather than stored: the catalog document and the bundles. The catalog is built on every request from published listings that have at least one approved, un-yanked version, and each entry carries the full manifest, an absolute downloadUrl, the sha256, tags, the size in bytes and the readme when there is one. A listing with no servable version simply produces no entry. The manifest is served back exactly as it was stored, so a field this site has never heard of still reaches the host that has to understand it. Nothing in the document is host-specific, which is why one catalogue entry works for every installation pointed at it.
The response is cached for a minute with a long stale-while-revalidate window and served with 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 — a host reading it sees "no plugins" rather than a parse failure. When the version is chosen, the newest approved, un-yanked version wins, which is the same rule a host applies when it decides whether an upgrade is on offer.
Bundles are served from a filename that is treated as untrusted input. The name is resolved through the database before anything touches the filesystem, so an unknown name is a 404 even if a stray file happens to exist, and the stored path is re-checked against the storage root so a traversal attempt fails closed. Each download is attributed — the dev3d version and the operating system are parsed out of the user agent and stored as separate columns so the analytics page needs no regex, and the IP is only ever kept as a daily-salted truncated hash, enough to count distinct downloaders and not enough to identify one. A published version whose bytes have gone missing answers 410 Bundle unavailable, which is the signature of a restored database pointing at an empty storage directory rather than of a bad upload.
What a host then refuses is its own list, and it is worth knowing which failures happen on your side and which on theirs: a checksum that does not match the catalog entry, an apiVersion whose major is not the host's, a bundle with no plugin.json at its root or in a single wrapping directory, an archive containing a link or an unsafe path, and an installation that has not been told to allow installs at all. The archive rules and the install gate are on Publishing from CI and Environment variables, in full; the practical version of all of it is the table at the end of this page.
Updating
Two different things are called "updating" and they take different paths.
- A new version is a new submission: another upload, another release import, another API call. It goes through review like any other, and it does not inherit the previous version's approval. The only editorial fields it touches are the ones the manifest is authoritative for — kind,
apiVersion, permissions and the contribution counts — which are refreshed from the manifest on every upload so the listing's badges never describe a version that has been replaced. - Editing a listing is editorial and changes nothing about a version: the summary, description, category, tags, author, licence, homepage, repository, icon and accent colour live in the database, are editable from either the console or the publisher's own workspace with the same limits in both, and cannot alter what a host downloads.
On the installation side there is no push and no notification. An office learns about upgrades when it asks: the console can check every registered marketplace on demand, and the HTTP API exposes the same check. An offer is computed only for plugins that are actually installed, and only for a version that is strictly newer — so a plugin nobody has installed is never "updated", and a version that is not newer than the installed one produces the message x is already at 1.2.0; the marketplace offers 1.2.0. rather than a silent no-op. A plugin that ships with the checkout cannot be replaced by a marketplace bundle at all, and the refusal says why: x ships with the office; update it by updating dev3d. There is no downgrade path and no version pinning — declining an upgrade means not running it, and the offer will still be there next time.
Yanking and archiving
These are the two ways a thing stops being available, and they operate at different levels.
| Yank a version | Archive a listing | |
|---|---|---|
| Scope | One release of one plugin. | The whole listing: every version, present and future. |
| Who | Its publisher from their own workspace, or an administrator. | An administrator with the publish permission. |
| What happens to the row | It survives, flagged yanked — which is deliberate, because download rows point at it and a deletion would orphan the history that explains them. | It survives with status archived. The plugin id stays claimed. |
| Catalog and URL | Gone. The catalog filters yanked versions out, and the bundle route filters them too, so the URL answers 404 rather than serving bytes. | Gone. The catalog only builds entries for published listings, and the bundle route requires a published listing as well. |
| Existing installations | Unaffected. Nothing recalls a copy that is already installed; the next version they install simply will not be that one. | Unaffected for the same reason, and the archived status does not change what is already on disk. |
| Reversible? | Yes — the same control restores it, and a re-upload of the same version clears the flag. | Yes, by an administrator moving the listing back to published, which requires an un-yanked version to exist. |
The refusals are the useful part of this section, because they encode a decision about what a catalog entry means. A published listing with no servable version would be an entry pointing at a bundle that is gone — the one failure a checksum cannot catch, since there would be nothing left to hash. So yanking the only servable version is refused with This is the only servable version. Upload a replacement before yanking it., and a publisher withdrawing the last approved version of a published listing gets the longer version of the same answer, ending in the suggestion to ask an administrator to archive the listing instead. Two operations are therefore deliberately not the same act: withdrawing one release is a publisher's decision, and withdrawing a plugin is an administrator's.
A version that is not approved is handled differently, and this is where the two words diverge most clearly. A pending or rejected version has no download history to preserve, so a publisher withdrawing it deletes the row and the bytes outright. An approved one is yanked instead. The distinction matters to anyone reading the audit log, because the two produce different actions: plugin.version_yanked for the first, plugin.version_deleted for the second. Deleting a whole listing is an administrator's action and removes rows before bytes — a stranded file is recoverable, whereas a row pointing at a file that is gone is a broken catalog entry.
Verification and trust
Two flags hang off a publisher account, and the reason they are two rather than one is the most interesting decision in the marketplace.
publisherVerified- An administrator has confirmed that this account is who it claims to be. It asserts identity and nothing else — not quality, not safety, not that anyone read the code. It is what the verified badge on a plugin page means, and it is granted by a person in the console, never by a form. On its own it changes exactly one visible thing: the marketplace will name this account as the vendor of its listings.
publisherTrusted- This account's submissions skip the review queue. It is a genuine reduction in oversight — a trusted publisher's version reaches installations without a person having looked at it — so it is a separate switch rather than something verification quietly grants. Approving a verification request sets the first flag and deliberately leaves the second alone, and the confirmation says so out loud: "… is now a verified publisher. Their releases are still reviewed until you mark them trusted."
publisherSuspended- The moderation switch, covered below. It is separate from both, and separate again from the account's own status, so a person can be stopped from shipping without being stopped from signing in.
The point of splitting verification from trust is that the two are different judgements and are usually made at different times. Verifying somebody is a statement about who they are; deciding their releases need no review is a statement about their process. Folding them together would mean that the first click — verifying an identity — silently removed a human from the loop for every future release, and an installation that wanted to trust a known publisher without verifying their legal identity, or verify somebody whose releases still need a look, would have no way to say so. So verification never grants trust, and trust is granted explicitly, in a different place in the console.
Three further details are easy to conflate, and the first is a gap rather than a feature. The site has a verification queue and no implemented way to file into it. There is a table for requests, a review screen that reads it, and a panel in the publisher workspace that displays a pending or declined request — and nothing in the codebase ever creates a request row. The rate limit that was meant to bound applications is still declared and never consumed, which is the same fact seen from a different angle. So in this build verification is granted directly by an administrator on the publishers screen, and the queue stays empty: a request for verification has to arrive the way everything else outside the software does, through contact. Second, there is no self-service route to trust at all — trust is not requestable, and the only way to have it is to be given it. Third, the badge on a plugin page is not the same badge as the verified flag on a plugin record: a listing also carries a first-party verified flag and a featured flag, which an administrator sets with the publish permission to mark something as shipped by the project itself, and which say nothing about the account that published it. The publisher's badge is driven by the account; the manifest's author string is attribution the plugin chose, and the two are displayed separately rather than merged into one claim. The marketplace policy is where the editorial rules for both live.
Suspension
Suspension is the narrowest of the three moderation states, and its narrowness is deliberate. It stops an account from shipping, and it does not do anything else.
- What it stops: claiming or editing a listing, uploading a version, connecting or syncing a GitHub repository, and publishing through the API. The API answers
403withPublishing is suspended for this account.; the workspace refuses before a byte is read. - What it does not stop: signing in. Suspension is not account suspension — the account's own status is a separate field with its own message,
This account is suspended. Contact an administrator.— and a suspended publisher can still reach their account, their profile and the rest of the site. - What it does not recall: anything already published. Listings keep working, entries stay in the catalog, bundles keep being served, and installations keep what they have. Suspension stops somebody shipping, it does not withdraw what shipped — if that is what you want, it is yanking and archiving.
- What it does change, visibly: the marketplace stops naming the account as the vendor of its listings. The listing keeps working; the publisher attribution on it disappears, which is the visible half of the same decision.
One implementation detail makes suspension more useful than a session check would be: the state is re-read from the database on every action that ships bytes, rather than trusted from the session. An account suspended while a tab is open must not still be able to upload, so the check is deliberately repeated at the point of the write. And the check fails closed when the account cannot be found at all — a missing row reads as suspended. The installation's owner cannot be suspended as a publisher, because there would be no second owner to undo it: The installation owner cannot be suspended as a publisher.
What can go wrong
The realistic failures, in rough order of how often each one is the answer. Every row here ends in a person, not a queue, so the trick is usually to work out which person and what they are looking at.
| Symptom | What it really is | What to do |
|---|---|---|
| An upload is refused because the manifest id does not match the listing. | The archive declares a different plugin from the one it was attached to. This is the check that stops a listing advertising one plugin and serving another. | Upload the bundle that belongs to this listing, or claim a listing for the id the manifest actually declares. A listing's id cannot be edited afterwards. |
| A GitHub import fails with the same error every time. | An import is attempted at most three times, counted across syncs, and then left alone. The ceiling exists so that one release whose archive is shaped wrong stops being re-downloaded on every sync, spending the API budget on something already known to be broken. | Fix the release — usually the archive has no plugin.json where the site can find one, or its manifest is invalid — and republish the tag. A rejected release row is kept even after a rejection, so a sync does not re-offer what an administrator already turned down. Plugins and skills has the archive-shape cases. |
A sync fails with The stored access token could not be decrypted, which means SESSION_SECRET changed. | The token is encrypted at rest under a key derived from the site's session secret. Rotating that secret makes every stored token undecryptable, by design rather than by accident. | Re-enter the token on the connection. Nothing else is lost: releases already imported stay imported, and the repository connection itself is intact. |
GitHub refuses: GitHub refused the request. Check that the access token is valid and has access to this repository. | An expired, revoked or under-scoped token. The message covers every case deliberately, because a token that expired and a token that was never valid look the same from here. | Replace the token on the connection. For a repository that is private, note that a public repository with a broken token fails differently: the message names the repository instead. |
| An installation refuses a bundle with a checksum mismatch. | The bytes it downloaded are not the bytes that were hashed at upload. The catalog is advertising a digest the file no longer produces. | Run the console's verify action on that version. It re-reads the stored bundle and re-computes the hash, and reports either a match with the byte count or a mismatch with both digests — which tells you whether storage drifted or something in front of the site is serving different bytes. |
| A version was rejected and the publisher does not know why. | They do — the note is on their own listing page beside that version — but a rejection cannot be submitted without one, so an empty note is not a possible state. | Read the note on the version row in the review queue. If the publisher says there is nothing there, they are looking at a different version, or the note was written on the listing rather than the version. Plugins and skills. |
| A listing is published but has no catalog entry. | Publishing the listing is not the same as having something servable. The catalog requires at least one approved, un-yanked version. | Check the versions: an approved version that has since been yanked leaves a published listing with nothing to serve. Upload a replacement, or un-yank the version. |
| An install is refused with a message about the install gate. | Not a marketplace problem at all. The installation has not been told to allow installing bundles, which is the shipping default. | Nothing to change on this side. The host's environment needs DEV3D_ALLOW_PLUGIN_INSTALL=true; see Environment variables, in full. |
| A version cannot be withdrawn. | It is the only servable one. Withdrawing it would leave a published listing pointing at a bundle that is gone. | Upload the replacement first, then yank the old one — or ask an administrator to archive the listing if it should not be offered at all. |
What the marketplace does not do
Every entry here is a deliberate limit of an installation that runs no always-on worker, and each one has a consequence a publisher will meet.
- There is no webhook ingest and no background worker. A webhook needs something listening all the time, and this project deliberately does not run one. Releases are pulled when a publisher presses sync, or when a pipeline calls the API, so a release tagged on a Friday is not in the marketplace until somebody asks for it. The connection's last-sync time and last error are stored for exactly that reason: a connection that has quietly stopped working is visible instead of silently importing nothing.
- Nothing publishes automatically. A submission reaches the catalogue when a person approves it, or immediately when the account is trusted. There is no scheduled job and no way for a plugin to promote itself; trust is the only shortcut, and it is granted by a person.
- There is no email notification. A review decision, an approval and a rejection all appear in the publisher's own workspace. Nobody is told by email, because a worker that sends mail is the same always-on component this installation does not have — if a process depends on being told, the telling is yours to arrange.
- There are no private listings. A published listing is public: its page, its catalog entry, its bundle URLs and its download counts. What is private is a version that has not been approved, and it is private in the strong sense — both the catalog and the bundle route filter it, so a guessed filename is not a way around the queue. A plugin that must be restricted to certain installations is not a listing; it is a directory somebody drops into their own plugins folder.
- There is no recall, no downgrade and no pinning. An installation that has a version keeps it; a yank does not reach back into it. An upgrade is offered only when it is strictly newer, so rolling back means removing the plugin and installing the older version as a fresh install. And nothing records "stay on this version" — declining an upgrade means not running it, and the offer remains.
If you are building the other side of this — the installation rather than the listing — the two pages that matter are Building a plugin for the manifest and Plugins and skills for when an install refuses. If a listing of yours is being refused by a rule you cannot find on this page, ask: the policy behind the rules is on the marketplace policy, and the honest answer when something is not covered is that the catalogue is curated rather than that the rule does not exist.
Where to go next
Linked from
Did this page answer your question?