Extensions, product ids and version sources

The three places a version number comes from and why they disagree, the extension manager's screens and routes, what an update download does and does not do, and how to record versions before you update.

10 min readUpdated 6 August 2026extensions, versions, updates, product-id, changelog

"Which version am I on, and is there an update?" is a daily question with a genuinely ambiguous answer. Three different numbers can appear on one screen, each answering a different question, and none of them is wrong.

This page is the model. For what a licence is and how activation works, see Licences and activation; for how to apply an update safely, see Updating.

The three version sources

Source Where it lives What it means
Installed version The version column on the product's extension, ecosystemBlockchain or exchange row What the database believes is on disk. Written by the update download when it succeeds, and by the seeder on first insert.
licenseVersion Returned by the licence service as latest_version, per product The newest version the licence service will hand you.
The catalogue The published release notes, fetched from the documentation host The newest version that has been published.

The extension listing computes hasLicenseUpdate by comparing the first two with a plain numeric part-by-part comparison — for each dotted segment, a higher segment on the licence side wins, a higher segment on the installed side loses, missing segments count as 0.

The update check can answer "6.0.2 is available" while the published notes run to 6.1.1. Two services, two caches, two different questions — "what can I install right now" versus "what has been released". Neither number is wrong. The product detail page says so in one line when the gap is real, rather than looking broken.

There is a fourth number, and it is the one people quote by accident: the version field in the project's root package.json. That is the core product's version — it is what GET /api/admin/system/product reports and what the batch update check sends as core's current version. It says nothing about any addon.

Where an addon's installed version comes from

The version column is only set by the seeder on insert; the seeder's update branch deliberately leaves it alone so the licence sync stays its owner. A row that was inserted without an explicit version lands on the model default 0.0.1, which is why an install can show an addon as six major versions behind a build that was never shipped.

The batch check repairs one specific case of this: if the install sends 0.0.1, the licence service reports an update available, and lic/<productId>.lic exists, the row is written up to the licence service's latest_version and the response is corrected to "you have the latest version" — on the reasoning that a licensed addon's files are already there. It is a heuristic, and it means a freshly-activated addon can jump from 0.0.1 to the current version without anything being downloaded.

The screens

Admin → System → Extension Manager, at /admin/system/extension, gated on access.extension. A grid or list of every extension, blockchain and exchange provider, filterable by category (?type=extension, blockchain, exchange) and by status, with a licence chip, an enabled switch and an update badge per card.

The product detail page, at /admin/system/extension/[id] — the [id] is the product's productId, not a row id. Same permission. Three tabs:

Current version, latest version, the licence and enabled chips, the Install button when an update is offered, and a rail of published releases.

The release notes for the selected version. It opens on the version you are being offered — that is the document you need before pressing Install — and otherwise on the newest published release.

What the product adds.

An update-all dialog on the listing drives the same download route across every installed product in turn.

The routes

Lists every extension, blockchain and exchange provider with licence and update status
Turns one product on or off — the id is its productId
Turns several products on or off at once

The listing route returns, per row:

Field Meaning
version The installed version from the product's own row.
status The enabled flag on that row.
licenseVerified Whether lic/<productId>.lic exists on disk. An existence check from a single directory read — not a decryption test.
hasLicenseUpdate true when the licence service reports a version newer than version.
licenseVersion The licence service's latest_version.
licenseReleaseDate · licenseSummary The licence service's metadata for that release.
category extension, blockchain or exchange.

Two things worth knowing about this endpoint. It calls the licence service's batch check, which costs an outbound HTTPS round trip plus a public-IP lookup — about a second — so the answer is served from a 10-minute cache with concurrent callers collapsed onto one in-flight request. Degraded results ("no licence", "batch check unavailable") are deliberately not cached, so they retry. And the swap extension is filtered out of this listing entirely, so its absence from the extension manager is expected rather than a missing row.

Both status routes call CacheManager.clearCache() after the write. That is not scoped to the product — it drops the platform cache, so menus, the extension list, the operations inbox and every other cached surface rebuild on the next request. Harmless, but it means a toggle on a busy install produces a burst of cache misses. Toggling several products one at a time does it once per toggle; the bulk route does it once.

The single-product route throws on failure rather than returning an error object. That matters historically: returning { error: … } with a 200 made the frontend read it as success, so the switch flipped on screen while the database row was unchanged and the operator believed an extension was enabled when it was not.

Product identity

Reports the core product's id, name and version from package.json
Reports one product's row by its productId

With no id, this reads the root package.json and returns the core product — id and productId both the Envato item id (35599184 by default), name, version and description. With an id, it looks up the matching extension row.

The product id is the join key for everything licence-shaped. It is the <productId> in lic/<productId>.lic, the first argument to pnpm activate-product, the [id] in the detail page URL and in the status route, and what every update and patch-notes call sends. The core product's id is also settable as MAIN_PRODUCT_ID in .env, default 35599184 — leave it alone unless support says otherwise.

Update checking

Asks the licence service which versions are pending for one product
Asks once for every installed product at once
Asks for a product's newest version
Downloads one version and extracts it over the project root

All four require create.license — a different permission from the two that gate the extension screens. A role granted access.extension and edit.extension but not create.license sees the whole extension manager render and gets a permission error from every check and every download.

check returns pendingUpdates (every version newer than yours, oldest first), version and update_id for the next one only, latestVersion, and a changelog. Updates are applied one version at a time; the panel offers only the next one and re-checks after each download.

check/latest-version is a stub. The licence platform has no dedicated latest-version endpoint, so it always answers Version check not available via this endpoint with a null version. Use check.

check returns that exact message in two different situations: the licence service answered and had nothing, and the call threw and was swallowed. The response carries a checkFailed boolean and a failureReason to tell them apart — the bulk updater reads it, because a network blip mid-run would otherwise end its loop and report the install as fully updated when it is not. A missing purchase code answers No purchase code found under the same heading. Read the message line, not the heading.

update/download fetches the ZIP, extracts it over the project root, and updates the product's version column. That is all it does.

It runs no schema migration, no seeders, no pnpm install, and no frontend rebuild, and it restarts nothing — the running processes carry on executing the code they loaded at boot. Until you run pnpm updator in a shell on the server, the platform is serving the old code from a tree that has already been replaced underneath it. Do not leave it in that state overnight.

Extraction snapshots every file it overwrites into .update-backup-<timestamp> at the project root before writing, and restores the lot if any single file fails — so "Update extraction failed — rolled back to previous version" means the tree is the pre-update one and there is nothing to undo by hand. Delete the leftover .update-backup-* directory only once you have confirmed the platform is healthy on the new version.

The ZIP itself lands in updates/ at the project root and is deleted on both success and failure. A type field of extension, blockchain or exchange tells the route which catalogue table to write the new version to; without it the files are extracted and no row is updated.

Release notes

Manifest of every product and every published version — dates and tags, no prose
The notes for one product; accepts an item id, a patch-note type or a store slug

This is where the Releases tab on the product detail page and the release rail on Admin → System → System Updates get their text — the same catalogue, rendered through the same rail on both screens. Nothing is stored on your server: the browser calls a backend proxy which fetches from the documentation host. The manifest is roughly 25 KB — the body of a note is fetched only when one is opened. Pass ?since=<installed version> to the per-product route to get only what is newer.

The proxy fails gracefully and silently. If the docs host is unreachable from your server, the bundle comes back empty and the version rail is replaced by an empty-state card reading "No changelog available". The Releases tab does not disappear — it is still there, holding that card. That is a network symptom on your box, not a release that shipped without notes.

Do not confuse it with the other empty state. When the catalogue arrived but the one version you have selected carries no prose, the notes panel reads "No release notes were published for this version." while the rail beside it still lists versions. A populated rail means the fetch worked.

The rail and the published release-notes pages draw on the same upstream source, but only one of them depends on your server having outbound access to it. If the panel shows nothing, read the notes directly at /docs/releases before concluding a release is undocumented.

Note also that the changelog string carried inside an update-check response is a separate, thinner field — the panel falls back to it when the proxy returns nothing — so sparse notes against a version are usually that fallback, not the note.

Enabling a feature takes two things

This is the distinction that costs the most time, and it is why a correctly purchased addon can sit there doing nothing.

Flag Where Without it
The licence lic/<productId>.lic on disk The product's API routes answer 403.
The status flag status on the extension / ecosystemBlockchain / exchange row The menus, screens and cron jobs never appear.

A .lic alone unblocks the gate and shows you nothing. A status flag alone switches on a menu that then 403s. They are set from different places — the licence from the activation screen, the status from the extension manager's toggle.

pnpm activate-product <productId> <purchaseCode> [clientName]

does both in one pass, plus a revalidate so the gate opens without a restart.

Turning a product off is the correct way to retire a feature: it updates the row and leaves the .lic in place, so turning it back on needs no reactivation. Deleting the .lic is not.

After an update: remove what the release deleted

Extraction only ever writes. A file a release removed is still on your disk afterwards, and that is not always harmless. Every page.tsx under frontend/app is a route, so a deleted page keeps building — and keeps failing — on an updated install; and a deleted module whose name matches a directory beside it wins module resolution over the directory that replaced it.

pnpm clean:stale --check    # preview, always exits 0
pnpm clean:stale            # delete
pnpm clean:stale:check      # preview, exits 1 if anything would be removed

The script works in four passes: an explicit list of retired paths, whole directories a release removed, anything that shadows a sibling index barrel, and then — the important one — a pass that puts files back if anything still on disk imports them. That last pass exists because the first three assume the update landed completely; on one install that received the new scripts/ but kept the previous release's frontend tree, the script deleted a client component a still-present page imported and the build died 21 times over.

Run it after pnpm updator, then rebuild the frontend if it removed anything.

Permissions

Key Grants
access.extension Loading /admin/system/extension and the product detail pages.
view.extension The listing endpoint behind them.
edit.extension Both status-toggle routes.
create.license Every update check, the download, and every activation route.

New permission gates ship strict — a newly gated screen is reachable by Super Admin only until the key is granted per role under Admin → Users → Roles & Permissions. pnpm check:permission lists which screens are waiting on a grant. See Roles and permissions.

Record what you are on, before you update

The comparison you will want afterwards is "what changed", and there is no screen that answers it retrospectively. Capture the state first.

  1. Every installed version, in one call, from a shell on the server with an authenticated session — or read them off the extension manager and write them down. The listing is the only place all three sources appear together.

  2. Core's version is the version field in the project's root package.json:

    node -p "require('./package.json').version"
  3. Which products are licensed, which is a directory read:

    ls lic/

    Each filename is a product id. This is also the list you will need if you ever rebuild on new hardware — see Moving an install to a new server.

  4. Which are enabled. The status column in the extension manager. A product that was off before the update should still be off after it.

  5. Take the database dump and the copy of .env that Backup and restore asks for. The version list is not a backup.

Afterwards, the badge on Admin → System → System Updates should read the version you applied, and the extension manager should show no remaining update badge on anything you updated.