domma-cms 1.6.60 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4349 -0
- package/README.md +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/lib/effect-defs.js +1 -1
- package/admin/js/lib/effects-builder.js +2 -2
- package/admin/js/lib/mail-compose.js +1 -0
- package/admin/js/lib/page-editor-reference.js +5 -5
- package/admin/js/lib/shortcode-tags.js +1 -1
- package/admin/js/templates/call-new.html +1 -1
- package/admin/js/templates/docs/usage-shortcodes.html +39 -0
- package/admin/js/views/call-new.js +1 -1
- package/admin/js/views/contacts.js +15 -15
- package/admin/js/views/form-submissions.js +2 -2
- package/docs/DommaFeatures.md +3005 -0
- package/docs/MultiTenant.md +176 -0
- package/docs/api-reference.md +1667 -0
- package/docs/cache.md +96 -0
- package/docs/configuration.md +1811 -0
- package/docs/editions-and-licences.md +190 -0
- package/docs/getting-started.md +201 -0
- package/docs/guides/form-triggers.md +260 -0
- package/docs/guides/mongodb-storage-adapter.md +282 -0
- package/docs/guides/multi-page-forms.md +121 -0
- package/docs/images/scribe-composer.png +0 -0
- package/docs/markdown-shortcodes.md +1738 -0
- package/docs/pentest-report-2026-04-03.md +181 -0
- package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
- package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
- package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
- package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
- package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
- package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
- package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
- package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
- package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
- package/docs/plans/2026-03-05-user-types-design.md +139 -0
- package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
- package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
- package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
- package/docs/plans/2026-06-10-core-project-design.md +240 -0
- package/docs/plugin-development.md +827 -0
- package/docs/recruitment-recipe.md +501 -0
- package/docs/scaffolding.md +312 -0
- package/docs/screenshots/calculator-floating.png +0 -0
- package/docs/screenshots/calculator-standard.png +0 -0
- package/docs/screenshots/email-settings.png +0 -0
- package/docs/screenshots/messages-count.png +0 -0
- package/docs/structure/user-types.md +107 -0
- package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
- package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
- package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
- package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
- package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
- package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
- package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
- package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
- package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
- package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
- package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
- package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
- package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
- package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
- package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
- package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
- package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
- package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
- package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
- package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
- package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
- package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
- package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
- package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
- package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
- package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
- package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
- package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
- package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
- package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
- package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
- package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
- package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
- package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
- package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
- package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
- package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
- package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
- package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
- package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
- package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
- package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
- package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
- package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
- package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
- package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
- package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
- package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
- package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
- package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
- package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
- package/docs/theming.md +320 -0
- package/package.json +2 -2
- package/plugins/_lib/admin/mail/compose-window.js +3 -1
- package/plugins/_lib/admin/mail/identity.js +18 -3
- package/plugins/_lib/admin/mail/reader-view.js +32 -3
- package/plugins/blog/CLAUDE.md +4 -0
- package/plugins/blog/admin/lib/email.js +191 -0
- package/plugins/blog/admin/views/comments.js +3 -1
- package/plugins/blog/plugin.json +2 -2
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/email.test.js +69 -0
- package/plugins/booking/CLAUDE.md +4 -0
- package/plugins/booking/admin/lib/email.js +191 -0
- package/plugins/booking/admin/views/panels.js +7 -3
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/tests/email.test.js +69 -0
- package/plugins/free-tier.lock.json +27 -19
- package/plugins/shopping-cart/CLAUDE.md +4 -0
- package/plugins/shopping-cart/admin/lib/email.js +191 -0
- package/plugins/shopping-cart/admin/views/orders.js +11 -3
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/tests/email.test.js +69 -0
- package/plugins/support/CLAUDE.md +4 -0
- package/plugins/support/admin/lib/email.js +191 -0
- package/plugins/support/admin/views/desk.js +4 -1
- package/plugins/support/admin/views/inbox.js +6 -2
- package/plugins/support/plugin.json +2 -2
- package/plugins/support/tests/email.test.js +69 -0
- package/public/js/celebrations/core/canvas.js +1 -1
- package/public/js/celebrations/index.js +1 -1
- package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
- package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
- package/public/js/celebrations/message-attrs.mjs +1 -0
- package/public/js/celebrations/themes/anniversary.js +1 -0
- package/public/js/celebrations/themes/autumn.js +1 -0
- package/public/js/celebrations/themes/birthday.js +1 -0
- package/public/js/celebrations/themes/butterfly-flight.js +1 -0
- package/public/js/celebrations/themes/calendar.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year.js +1 -0
- package/public/js/celebrations/themes/christmas.js +1 -1
- package/public/js/celebrations/themes/easter.js +1 -0
- package/public/js/celebrations/themes/garden-drawing.js +1 -0
- package/public/js/celebrations/themes/garden.js +1 -0
- package/public/js/celebrations/themes/graduation.js +1 -0
- package/public/js/celebrations/themes/greeting.js +1 -0
- package/public/js/celebrations/themes/make-it-rain.js +1 -0
- package/public/js/celebrations/themes/money-rain.js +1 -0
- package/public/js/celebrations/themes/new-year.js +1 -0
- package/public/js/celebrations/themes/occasion-drawing.js +1 -0
- package/public/js/celebrations/themes/occasions.js +1 -0
- package/public/js/celebrations/themes/rain-control.js +1 -0
- package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
- package/public/js/celebrations/themes/sparkler-font.js +1 -0
- package/public/js/celebrations/themes/sparkler-writing.js +1 -0
- package/public/js/celebrations/themes/summer-garden.js +1 -0
- package/public/js/celebrations/themes/winter.js +1 -0
- package/public/js/celebrations/trait-data.mjs +1 -1
- package/public/js/effects.js +1 -1
- package/scripts/build.js +4 -0
- package/server/services/markdown.js +5 -2
- package/server/templates/page.html +1 -1
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
# dcms-marketplace - one source of truth, three delivery paths - design
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-09-22
|
|
4
|
+
**Status:** Proposed design - not yet approved
|
|
5
|
+
**Repos:** `dcms-marketplace` (new, private), `domma-cms`, `domma-cms-manager`
|
|
6
|
+
**Builds on:** [Plugin Monetisation Split](./plans/2026-04-08-plugin-monetisation-split.md) (decisions still stand),
|
|
7
|
+
[Phase 2 Plugin Distribution](../../../../domma-cms-manager/docs/superpowers/specs/2026-03-30-phase2-plugin-distribution-design.md) (manager, shipped)
|
|
8
|
+
|
|
9
|
+
Gives every plugin - free and paid - a single home in git, generates the catalogue from that home
|
|
10
|
+
rather than by hand, and adds a second delivery path alongside the existing fleet push: a signed
|
|
11
|
+
standalone bundle a paying customer downloads once and installs offline on their own CMS.
|
|
12
|
+
|
|
13
|
+
That bundle is also the **interchange unit in both directions**: any plugin can be exported from a
|
|
14
|
+
running CMS as a single file and imported into another, which is the same operation a purchase
|
|
15
|
+
performs and therefore the same code path.
|
|
16
|
+
|
|
17
|
+
## Goal
|
|
18
|
+
|
|
19
|
+
Today a plugin's code can live in four places and its identity in seven. The runtime is fine -
|
|
20
|
+
`discoverPlugins()` scans `plugins/` and the manifest is authoritative for one site - but there is
|
|
21
|
+
no truth for the *estate*: a plugin's canonical copy is in `domma-cms`, or in the manager, or inside
|
|
22
|
+
the Site Manager plugin, depending on which plugin you mean, and five exist only on a live site.
|
|
23
|
+
|
|
24
|
+
Five outcomes:
|
|
25
|
+
|
|
26
|
+
1. **One repo holds every plugin.** Free and paid, one slug each, with history.
|
|
27
|
+
2. **The catalogue is derived**, never hand-maintained, and signed.
|
|
28
|
+
3. **Three delivery paths from one artifact set**: fleet push-copy (as now, unchanged), a signed
|
|
29
|
+
standalone bundle for offline install by a customer who has paid, and plain export/import
|
|
30
|
+
between any two installations.
|
|
31
|
+
4. **A plugin is one portable file.** Export it from any CMS, import it into any other, with no
|
|
32
|
+
manual copying of directories and no second format to maintain.
|
|
33
|
+
5. **Commercial terms are editable, not compiled.** Renting to a managed site and selling outright
|
|
34
|
+
to an unmanaged one are different products; both are priced from a screen in Site Manager, not
|
|
35
|
+
from a field in git.
|
|
36
|
+
|
|
37
|
+
Non-goal: making Site Manager the public marketplace host. See [Why Site Manager does not host the
|
|
38
|
+
public catalogue](#why-site-manager-does-not-host-the-public-catalogue).
|
|
39
|
+
|
|
40
|
+
## Prior art found during the audit
|
|
41
|
+
|
|
42
|
+
Ten discoveries, all verified. Several change the shape of the work, and #8 is a live leak.
|
|
43
|
+
|
|
44
|
+
1. **The client half is complete and already shipped to npm.** `server/services/managerClient.js`
|
|
45
|
+
has `registerInstance` (:52), `fetchCatalogue` (:86), `fetchPluginBundle` (:119) and
|
|
46
|
+
`heartbeat` (:162). `server/services/pluginInstaller.js` does real RSA-SHA256 verification
|
|
47
|
+
against `TRUSTED_KEYS['manager-2026']`, parses tar without a dependency, guards path traversal,
|
|
48
|
+
writes an audit log to `content/instances_log.json`, and uninstalls. `pluginMarketplaceRoutes`
|
|
49
|
+
is registered at `server/server.js:448`, and the **Marketplace tab is live** in the Plugins
|
|
50
|
+
screen (`admin/js/views/plugins.js:6`).
|
|
51
|
+
|
|
52
|
+
2. **The manager serves none of the four endpoints the client calls.** No route anywhere answers
|
|
53
|
+
`/api/plugins/catalogue`, `/api/plugins/install/:slug/:version`, `/api/instances/register` or
|
|
54
|
+
`/api/instances/heartbeat`. The client has been calling into the void since it landed.
|
|
55
|
+
|
|
56
|
+
3. **`registerInstance()` and `heartbeat()` have no callers at all.** Not dormant - never wired.
|
|
57
|
+
|
|
58
|
+
4. **The private signing key is missing.** `pluginInstaller.js` names its counterpart as
|
|
59
|
+
`domma-cms-manager/config/keys/plugin-signing-2026.pem`; `config/keys/` does not exist on the
|
|
60
|
+
manager. The public key is genuine (RSA-2048, verified with `crypto.verify('sha256', …)`), not
|
|
61
|
+
the placeholder the code explicitly guards against, so the verification path works and **nothing
|
|
62
|
+
can currently produce a bundle that passes it**. Confirmed lost on 2026-09-22 and resolved by
|
|
63
|
+
rotation - see [Signing keys](#signing-keys).
|
|
64
|
+
|
|
65
|
+
5. **`MANAGER_URL` is unset on every fleet site**, so the Marketplace tab reports unavailable
|
|
66
|
+
fleet-wide. Harmless, but it is shipped UI pointing at nothing.
|
|
67
|
+
|
|
68
|
+
6. **Licences never leave the manager.** No paid plugin contains a licence check, no licence file
|
|
69
|
+
reaches a child site, and the CMS's `pluginLicence()` (`server/services/plugins.js:796`) is a
|
|
70
|
+
*display string* derived from `closedSource`. Enforcement today is entirely "the manager owns
|
|
71
|
+
the filesystem": revoke means the manager deactivates the files. Sound for directories on our
|
|
72
|
+
own host; it cannot survive a download onto a disk we do not own, where the first copy is
|
|
73
|
+
permanent.
|
|
74
|
+
|
|
75
|
+
7. **There is no upgrade path.** `installPlugin()` refuses if `plugins/<slug>/` exists - install and
|
|
76
|
+
uninstall only. `pluginDistributor.update()` has version-swap logic, but only on the manager side.
|
|
77
|
+
|
|
78
|
+
8. **Surveys Pro is published to npm source-open.** It was designed as a Pro plugin
|
|
79
|
+
([spec](./2026-06-26-surveys-pro-plugin-design.md)) but developed at `domma-cms/plugins/surveys`
|
|
80
|
+
with no `closedSource` flag, it is present in `_publish/plugins/surveys`, and a `closedSource`
|
|
81
|
+
duplicate sits at `plugins/site-manager/paid-plugins/surveys` at the same version 1.0.0. This is
|
|
82
|
+
precisely the leak the 2026-04-08 split existed to prevent, and it must be settled before a
|
|
83
|
+
catalogue is generated, because one slug cannot carry two tiers.
|
|
84
|
+
|
|
85
|
+
9. **Restart after install is already solved.** `hasSupervisor()`
|
|
86
|
+
(`server/routes/api/plugins.js:37`) detects pm2 and the fleet manager's IPC channel, restarts
|
|
87
|
+
are debounced so four toggles cost one exit, and `restartOnPluginToggle` in `config/server.json`
|
|
88
|
+
lets an admin opt out. An install can reuse all of it.
|
|
89
|
+
|
|
90
|
+
10. **The scaffolder copies paid plugins to every new site.** `siteScaffolder.js:277` holds a
|
|
91
|
+
hand-written `SKIP_PLUGINS` set, and it has drifted: it excludes `job-board`, `theme-roller`,
|
|
92
|
+
`data-transfer` and `garage`, but not `calendar`, `docs`, `email-pro`, `invoice`, `messages` or
|
|
93
|
+
`surveys`. So **six paid plugins land on every new site's disk**, unlicensed and disabled.
|
|
94
|
+
`pj-plastering` holds five of them and has no licence record at all. Once entitlement
|
|
95
|
+
verification lands, each of those copies becomes a plugin reporting `"unlicensed"` in the admin,
|
|
96
|
+
which is correct behaviour and a terrible first impression. This is the same hand-maintained-list
|
|
97
|
+
failure the rest of this design exists to remove.
|
|
98
|
+
|
|
99
|
+
11. **The paid plugins are in git, but in an awkward place; five other plugins are in no git at
|
|
100
|
+
all.** `paid-plugins/` is tracked in the manager repo (96 files) and mirrored to the private
|
|
101
|
+
plugin repo by `sync-plugin-repo.sh`, which excludes only runtime data - so `analytics-pro`,
|
|
102
|
+
`data-transfer`, `mongodb-adapter`, `job-board`, `theme-roller` and `email-pro` all have
|
|
103
|
+
history, in two places. What is awkward is *where*: they sit inside another plugin
|
|
104
|
+
(`plugins/site-manager/paid-plugins/`), so a paid product's history is entangled with the fleet
|
|
105
|
+
supervisor's. The site copies are minified derivatives, since
|
|
106
|
+
`pluginDistributor.minifyAdminCode()` runs on the destination at `activate()`.
|
|
107
|
+
|
|
108
|
+
The five **site-only** plugins are versioned too, in their sites' own repos:
|
|
109
|
+
`reactive-showcase` (21/21 files tracked in the `reactive` repo), `retro64-games` (6) and
|
|
110
|
+
`retro64-cors` (3) in `retro-64`, `cache-autoflush` (3) in `pinpointzero`. **The single
|
|
111
|
+
genuinely unversioned plugin is `shopping-cart`**, because the `domma-cms-site` site directory
|
|
112
|
+
is not a git repo at all - which is a bigger problem than the plugin, since the same is true of
|
|
113
|
+
everything else on that site.
|
|
114
|
+
|
|
115
|
+
`reactive-showcase`'s 9,587 LOC also needs qualifying: 7,999 of them are
|
|
116
|
+
`public/domma-reactive.esm.js`, a vendored build of a library that has its own repo and npm
|
|
117
|
+
package. Its own code is about 1,588 LOC.
|
|
118
|
+
|
|
119
|
+
## Decisions
|
|
120
|
+
|
|
121
|
+
| Decision | Resolution |
|
|
122
|
+
|---|---|
|
|
123
|
+
| Code truth | One **private monorepo `dcms-marketplace`**, `plugins/<slug>/`, every plugin free and paid. |
|
|
124
|
+
| Free tier and npm | The free subset is **synced into `domma-cms/plugins/` at release**, so `npm install` keeps shipping the free tier and free plugins keep working with no network. |
|
|
125
|
+
| Catalogue truth | **Generated** from the manifests by `make catalogue`. Never hand-written. |
|
|
126
|
+
| Public serving | **Static signed artifacts** on object storage plus CDN. No application server on the public path. |
|
|
127
|
+
| Site Manager's role | Fleet distributor (unchanged) **plus** standalone bundle dispenser. **Not** the public catalogue host. |
|
|
128
|
+
| Fleet delivery | **Push-copy stays.** For sites we own it beats pull: no network dependency, no licence race, and the manager already restarts children correctly. |
|
|
129
|
+
| Standalone delivery | Signed envelope downloaded from the Plugin Store, installed offline by admin upload. |
|
|
130
|
+
| Entitlement verification | Lives in **core (`server/`)**, not in the plugin. A plugin checking its own licence is the one file the customer can edit. |
|
|
131
|
+
| Licence binding, v1 | **Purchaser identity embedded, no hardware binding.** Copyable but traceable. Fingerprint activation stays available as v2. |
|
|
132
|
+
| Slug uniqueness | **Absolute.** One slug is one plugin at one tier, across both repos and the catalogue. |
|
|
133
|
+
| Free to paid upgrades | Use the existing `supersedes` mechanism, which already handles it correctly (`email-pro` supersedes `mail-reader`, and a lapsed licence restores the free plugin on next boot rather than losing both). |
|
|
134
|
+
| Signing key | **`manager-2026` is gone and is removed, not retained.** A new key `marketplace-1` replaces it, held offline. Rotation stays pinned by CMS version per the 2026-04-08 decision. |
|
|
135
|
+
| Signature algorithm | **Ed25519.** 64-byte signatures, no caller-chosen digest, and a mismatched digest argument throws rather than degrading. `TRUSTED_KEYS` holds Ed25519 keys only. |
|
|
136
|
+
| Interchange unit | **One envelope format** (`.dcmsplugin`) for all three paths: catalogue bundle, paid download, and local export. No second format. |
|
|
137
|
+
| Exporting a paid plugin | **Refused.** `closedSource: true` never exports, on the same principle that already blocks the source editor. |
|
|
138
|
+
| Unsigned imports | Allowed only behind an **explicit admin confirmation**, and refusable outright by config. An export from a running CMS cannot be marketplace-signed. |
|
|
139
|
+
| Runtime data on export | **Excluded by default.** `data/` is the site's, not the plugin's. Opt in per export. |
|
|
140
|
+
| Commercial terms | Live in **Site Manager**, editable in the admin (`plugin-terms.json`). Never in the manifest, never in git, never in the catalogue build. |
|
|
141
|
+
| Managed sites | Paid plugins are **rented monthly**. Lapse stops the plugin, which is what the manager already does today. |
|
|
142
|
+
| Unmanaged sites | **Bought outright** - a hefty one-off including **12 months** support and updates, then an ongoing renewal for further updates. The plugin runs forever regardless. |
|
|
143
|
+
| Plugin builder | **Free permanently**, never entitlement-gated, never metered. Submissions to the marketplace arrive as a path C envelope. |
|
|
144
|
+
| Which plugins are paid | Recorded in the [Tier register](#tier-register). `blog` free; `invoice` paid under either model; `mongodb-adapter` paid, **managed only**. |
|
|
145
|
+
|
|
146
|
+
## Repository layout
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
dcms-marketplace/ (private)
|
|
150
|
+
├── plugins/
|
|
151
|
+
│ ├── analytics-pro/ imported from a live site
|
|
152
|
+
│ ├── blog/ free
|
|
153
|
+
│ ├── data-transfer/ imported from a live site
|
|
154
|
+
│ ├── email-pro/ from manager plugins/
|
|
155
|
+
│ ├── mongodb-adapter/ imported from a live site
|
|
156
|
+
│ ├── seo-pro/ imported from a live site
|
|
157
|
+
│ └── … one directory per slug, no exceptions
|
|
158
|
+
├── scripts/
|
|
159
|
+
│ ├── build-catalogue.js manifests -> catalogue.json + bundles + signatures
|
|
160
|
+
│ └── sync-free-tier.js free subset -> domma-cms/plugins/
|
|
161
|
+
├── keys/ public keys only; private keys never live here
|
|
162
|
+
└── Makefile catalogue, sync-free-tier, verify
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Manifest additions
|
|
166
|
+
|
|
167
|
+
Two optional fields on the existing manifest. Everything else is unchanged, so a plugin dropped
|
|
168
|
+
into a CMS still loads exactly as today.
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"name": "seo-pro",
|
|
173
|
+
"displayName": "SEO Pro",
|
|
174
|
+
"version": "1.0.0",
|
|
175
|
+
"closedSource": true,
|
|
176
|
+
"tier": "paid",
|
|
177
|
+
"supersedes": ["site-search"]
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
- `tier`: `"free" | "paid"`. Drives the free-tier sync, the catalogue split, and whether an
|
|
182
|
+
entitlement is required at load. Absent means free.
|
|
183
|
+
- **No price.** Commercial terms are deliberately not in the manifest - see
|
|
184
|
+
[Commercial terms](#commercial-terms). A manifest is a git artifact, so a price living there
|
|
185
|
+
would make "change the price of SEO Pro" a code change, a release and a fleet roll.
|
|
186
|
+
- `closedSource` keeps its current meaning and its current job - it already gates the source editor
|
|
187
|
+
(`pluginFiles.js`, `isClosedSource`) and already names the licence line. It is not the tier flag,
|
|
188
|
+
because a paid plugin could in principle be source-visible.
|
|
189
|
+
|
|
190
|
+
## The catalogue
|
|
191
|
+
|
|
192
|
+
`make catalogue` walks `plugins/*/plugin.json` and emits, into a publishable directory:
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
catalogue.json signed index
|
|
196
|
+
catalogue.json.sig
|
|
197
|
+
bundles/seo-pro-1.0.0.tar.gz one per plugin per version
|
|
198
|
+
bundles/seo-pro-1.0.0.tar.gz.sig detached signature over the tarball bytes
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`catalogue.json` entries carry the **artifact** facts only: `slug`, `version`, `displayName`,
|
|
202
|
+
`description`, `tier`, `icon`, `supersedes`, `requires` (npm deps, so the dependency warning
|
|
203
|
+
`pluginDistributor.checkDependencies()` already produces can be shown *before* purchase) and
|
|
204
|
+
`minCmsVersion`. Prices and terms are a separate file on a separate lifecycle
|
|
205
|
+
([Commercial terms](#commercial-terms)), because the catalogue is rebuilt on release and terms
|
|
206
|
+
change whenever we decide they do.
|
|
207
|
+
|
|
208
|
+
The existing client expects a `price` field from `fetchCatalogue()`. Site Manager merges
|
|
209
|
+
`terms.json` into the catalogue it serves, so that contract holds without pricing entering git.
|
|
210
|
+
|
|
211
|
+
Signatures are produced **offline**, with a key that never sits on the manager host. The signature
|
|
212
|
+
covers the tarball bytes, which is what `pluginInstaller.js` already verifies - no format change.
|
|
213
|
+
|
|
214
|
+
This generation step is what retires the hand-maintained lists: the stale **Bundled Plugins** table
|
|
215
|
+
in `README.md`, and `KNOWN_HUES` in `admin/js/lib/plugin-accent.js` (which still names retired
|
|
216
|
+
`site-search` and `form-builder`). Both become derived, or in the accent map's case simply deleted
|
|
217
|
+
in favour of the catalogue's `icon` plus the existing hash fallback.
|
|
218
|
+
|
|
219
|
+
## Delivery path A - the fleet (unchanged)
|
|
220
|
+
|
|
221
|
+
Site Manager keeps doing what it does now, because for sites we own it is the better design:
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
dcms-marketplace ──(git pull)──▶ manager paid-plugins/ ──(push-copy)──▶ sites/<slug>/plugins/
|
|
225
|
+
│
|
|
226
|
+
entitlements.js: licence + distribute + Mongo + restart
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`entitlements.js`, `pluginDistributor.js` and `licenceManager.js` are unchanged in behaviour. The
|
|
230
|
+
only change is provenance: `paid-plugins/` stops being the source of truth and becomes a checkout
|
|
231
|
+
of the monorepo, so `git status` in the manager is no longer where a paid plugin's history lives.
|
|
232
|
+
|
|
233
|
+
## Delivery path B - standalone offline install (new)
|
|
234
|
+
|
|
235
|
+
The customer pays, downloads one file, and installs it on a CMS that may never reach the internet.
|
|
236
|
+
|
|
237
|
+
### The envelope
|
|
238
|
+
|
|
239
|
+
A single file, `<slug>-<version>.dcmsplugin`, which is a gzipped tar containing three members:
|
|
240
|
+
|
|
241
|
+
```
|
|
242
|
+
plugin.tar.gz the plugin directory, exactly as the fleet path copies it
|
|
243
|
+
plugin.sig detached signature over plugin.tar.gz (base64)
|
|
244
|
+
entitlement.json signed entitlement, see below
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
This maps one-to-one onto the triple `installPlugin()` already accepts
|
|
248
|
+
(`{ tarball, signature, publicKeyId }`), so the installer needs an `entitlement` option and
|
|
249
|
+
nothing else. The envelope exists so the customer handles one file rather than three, and so a
|
|
250
|
+
tampered or mismatched trio cannot be assembled by accident.
|
|
251
|
+
|
|
252
|
+
### Install route
|
|
253
|
+
|
|
254
|
+
New: `POST /api/plugins/install-upload`, admin-only, multipart. It unpacks the envelope, hands the
|
|
255
|
+
triple to `installPlugin()`, and writes the entitlement to `plugins/<slug>/.entitlement.json`.
|
|
256
|
+
Signature failure, slug mismatch between envelope and manifest, and `minCmsVersion` failure each
|
|
257
|
+
refuse loudly - the existing installer already cleans up a partial extraction before rethrowing.
|
|
258
|
+
|
|
259
|
+
After a successful install the route reuses the restart machinery from finding #9, so the plugin's
|
|
260
|
+
routes and admin views register on the next boot without the operator being told to restart a
|
|
261
|
+
server by hand.
|
|
262
|
+
|
|
263
|
+
### Entitlement
|
|
264
|
+
|
|
265
|
+
An entitlement states which of the two commercial models it was issued under
|
|
266
|
+
([Commercial terms](#commercial-terms)), because the two expire in completely different ways.
|
|
267
|
+
|
|
268
|
+
```json
|
|
269
|
+
{
|
|
270
|
+
"slug": "seo-pro",
|
|
271
|
+
"model": "perpetual",
|
|
272
|
+
"versions": "<=1.4.0",
|
|
273
|
+
"licenceKey": "DOMMA-XXXX-XXXX-XXXX",
|
|
274
|
+
"purchaser": { "name": "…", "email": "…", "customerId": "…" },
|
|
275
|
+
"issuedAt": "2026-09-22T00:00:00.000Z",
|
|
276
|
+
"expiresAt": null,
|
|
277
|
+
"supportExpiresAt": "2027-09-22T00:00:00.000Z",
|
|
278
|
+
"gracePeriodDays": 7,
|
|
279
|
+
"keyId": "marketplace-1",
|
|
280
|
+
"signature": "…"
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
- `model`: `"rental"` (managed sites, monthly) or `"perpetual"` (unmanaged sites, bought outright).
|
|
285
|
+
- `expiresAt`: the rental term. **`null` for perpetual** - a bought licence does not expire.
|
|
286
|
+
- `supportExpiresAt`: perpetual only. When support and updates end. **Not** when the plugin stops.
|
|
287
|
+
- `versions`: the range this entitlement covers. For a perpetual licence it is resolved **at issue
|
|
288
|
+
time** to everything released up to `supportExpiresAt`, and a renewal reissues it with a later
|
|
289
|
+
ceiling. This is what a lapsed maintenance term actually costs the customer: not the software,
|
|
290
|
+
the newer versions of it.
|
|
291
|
+
|
|
292
|
+
Verified by a new **core** service, `server/services/pluginEntitlement.js`, called from
|
|
293
|
+
`registerPlugins()` for any manifest with `closedSource: true`. Named for the thing it checks
|
|
294
|
+
rather than as `entitlements.js`, which already exists on the manager side and does a different
|
|
295
|
+
job.
|
|
296
|
+
|
|
297
|
+
| Model | State | Outcome |
|
|
298
|
+
|---|---|---|
|
|
299
|
+
| rental | Within term | Loads normally. |
|
|
300
|
+
| rental | Within `gracePeriodDays` of `expiresAt` | Loads; `GET /api/plugins` reports `"grace"`. |
|
|
301
|
+
| rental | Past grace | **Does not load**, reported `"expired"`. Files stay on disk, so a renewal is a file drop and a restart. |
|
|
302
|
+
| perpetual | Support current | Loads; updates offered. |
|
|
303
|
+
| perpetual | Support lapsed | **Loads indefinitely**, reported `"support-ended"`. Updates are refused at install, not at load. The screen offers renewal; it does not nag. |
|
|
304
|
+
| either | Version outside `versions` | Install refused with the reason. An already-installed version is never retrospectively unloaded. |
|
|
305
|
+
| either | Missing or signature invalid | Does not load, reported `"unlicensed"`. |
|
|
306
|
+
|
|
307
|
+
The perpetual row is the one worth getting right. Somebody who paid a hefty one-off sum and then
|
|
308
|
+
declined the ongoing renewal **keeps what they bought**, forever, offline. Stopping their site
|
|
309
|
+
because a maintenance term lapsed would be indefensible, and would also punish exactly the customer
|
|
310
|
+
who paid the most up front.
|
|
311
|
+
|
|
312
|
+
Never silently disabled in `config/plugins.json`. Same reasoning as the supersedes case: writing a
|
|
313
|
+
lapse into stored state means a renewal cannot simply restore it.
|
|
314
|
+
|
|
315
|
+
No phone-home under either model. A rental's term is its offline window, so a 12-month rental works
|
|
316
|
+
for 12 months with no network; a perpetual licence needs no window at all. Renewal means downloading
|
|
317
|
+
a fresh `entitlement.json` and dropping it in - a route takes the file on its own without
|
|
318
|
+
reinstalling the plugin.
|
|
319
|
+
|
|
320
|
+
### What this does and does not achieve
|
|
321
|
+
|
|
322
|
+
Stated plainly because the alternative is pretending. Domma CMS is source-open: an operator who
|
|
323
|
+
wants to remove `pluginEntitlement.js` can, and minified admin code is
|
|
324
|
+
*unrewarding to read*, not protected - which is the honest framing
|
|
325
|
+
`pluginDistributor.minifyAdminCode()` already uses for itself.
|
|
326
|
+
|
|
327
|
+
The goal is therefore not prevention. It is that the honest path is frictionless, the dishonest
|
|
328
|
+
path is deliberate rather than accidental, and every issued copy carries its purchaser's identity.
|
|
329
|
+
Nothing in this design should be described internally or to customers as copy protection.
|
|
330
|
+
|
|
331
|
+
### Owner instances
|
|
332
|
+
|
|
333
|
+
Two installations are ours and run paid plugins with no licence records:
|
|
334
|
+
|
|
335
|
+
| Instance | Paid plugins enabled | What it is |
|
|
336
|
+
|---|---|---|
|
|
337
|
+
| `dcm.dommajs.org` (port 8192) | `docs`, `email-pro`, `garage`, `invoice` | the manager - **the testbed where plugins are developed**, and the management plane for every other site |
|
|
338
|
+
| `cms.dommajs.org` | `docs`, `invoice`, `messages` | the Domma CMS site |
|
|
339
|
+
|
|
340
|
+
Without a decision, entitlement verification would refuse to load those on boot. On the manager that
|
|
341
|
+
means the fleet's management plane loses half its admin, including the screens used to issue the very
|
|
342
|
+
licences that would fix it.
|
|
343
|
+
|
|
344
|
+
**Decision: a wildcard entitlement, not an exemption flag.** An owner instance holds an
|
|
345
|
+
`entitlement.json` whose `slug` is `"*"`, `model: "perpetual"`, `expiresAt: null` and no support
|
|
346
|
+
window, signed with `marketplace-1` like any other. The verifier gains one rule - a wildcard
|
|
347
|
+
entitlement satisfies any slug - and no second code path.
|
|
348
|
+
|
|
349
|
+
Why not the alternatives:
|
|
350
|
+
|
|
351
|
+
- **Not an environment flag or an "is this the manager" check.** That would be a branch that only
|
|
352
|
+
ever executes on our own machines, which makes it the least exercised and most rot-prone path in
|
|
353
|
+
the verifier, and the one place where a forgeable trigger would matter most. A wildcard entitlement
|
|
354
|
+
needs the private key, exactly like every other entitlement.
|
|
355
|
+
- **Not per-plugin self-issued licences.** The manager is where plugins are *written*. A plugin being
|
|
356
|
+
built today has no slug in any ledger, so per-plugin licensing would mean issuing a licence before
|
|
357
|
+
you can run your own new code, on the machine where that happens most.
|
|
358
|
+
|
|
359
|
+
It also removes an expiry to forget: the management plane cannot lose its admin because a renewal
|
|
360
|
+
lapsed, because there is nothing to renew.
|
|
361
|
+
|
|
362
|
+
**Scope.** Issued to those two instances only, by hand, and never distributed - `pluginDistributor`
|
|
363
|
+
copies plugins, not entitlements, so a customer site cannot receive one by accident.
|
|
364
|
+
|
|
365
|
+
This dovetails with the rule established when the scaffolder was fixed: `closedSource` lives on
|
|
366
|
+
**distribution** copies, not on an instance's own installs, so the manager's `plugins/` stay
|
|
367
|
+
editable in the code editor. Between the two, the testbed can both edit its plugins and run them,
|
|
368
|
+
and neither mechanism needs a special case for it.
|
|
369
|
+
|
|
370
|
+
## Delivery path C - export and import as one file
|
|
371
|
+
|
|
372
|
+
The same envelope, produced by a running CMS rather than by the build. This is what makes a plugin a
|
|
373
|
+
portable entity rather than a directory someone has to know how to copy: authored on a dev site,
|
|
374
|
+
exported, imported on production; handed to another operator; kept as a versioned artifact outside
|
|
375
|
+
the fleet.
|
|
376
|
+
|
|
377
|
+
### Export
|
|
378
|
+
|
|
379
|
+
`GET /api/plugins/:name/export` returns `<slug>-<version>.dcmsplugin`, admin-only, requiring
|
|
380
|
+
`plugins.develop`.
|
|
381
|
+
|
|
382
|
+
**What travels.** The whole plugin directory as `discoverPlugins()` sees it, which already includes
|
|
383
|
+
the parts a plugin needs to reconstitute itself on the far side: `plugin.json`, `plugin.js`,
|
|
384
|
+
`config.js`, `admin/`, `public/`, `templates/`, and the three scaffolding directories
|
|
385
|
+
`setupPlugin()` reads at first load - `collections/*/schema.json` (`plugins.js:485`), `forms/`
|
|
386
|
+
(:511) and `roles/` (:560). Import runs `setupPlugin()`, so collections, forms and roles are created
|
|
387
|
+
on the target exactly as they are on a fresh install.
|
|
388
|
+
|
|
389
|
+
**What does not travel:**
|
|
390
|
+
|
|
391
|
+
| Excluded | Why |
|
|
392
|
+
|---|---|
|
|
393
|
+
| `data/` | Site runtime state, and already treated as not-ours by the `files` exclusions in `package.json`. Opt in with `?includeData=1`. |
|
|
394
|
+
| Collection **entries** | A plugin's entries live in `content/collections/<slug>/`, outside the plugin. The *schema* travels, the rows do not. Moving rows is what Data Manager and collection export are for. |
|
|
395
|
+
| `config/plugins.json` state | Enabled and settings are the target site's decision. Import lands disabled, matching `installPlugin()` today. |
|
|
396
|
+
| `plugins/_lib/` | Shipped with the CMS, so a plugin that imports `../_lib/dataStore.js` resolves on any target. This is why `minCmsVersion` matters more than bundling shared code. |
|
|
397
|
+
|
|
398
|
+
**Refusals.** Export is refused for `closedSource: true`. A paid plugin is distributed to run, not
|
|
399
|
+
to be re-emitted as a redistributable file, and one-click export would otherwise turn every
|
|
400
|
+
licensed customer into a distribution channel. The precedent and the mechanism both exist:
|
|
401
|
+
`pluginFiles.js` already refuses source access on the same flag, reading the manifest and treating
|
|
402
|
+
an unreadable one as closed so the gate cannot fail open.
|
|
403
|
+
|
|
404
|
+
### Import
|
|
405
|
+
|
|
406
|
+
Import is `POST /api/plugins/install-upload` - the same route the paid path uses, because the
|
|
407
|
+
envelope is the same. The difference is trust, and it has to be faced directly: a bundle exported
|
|
408
|
+
from a running CMS **cannot carry a marketplace signature**, because the signing key is offline and
|
|
409
|
+
is never on a customer's host.
|
|
410
|
+
|
|
411
|
+
So `plugin.sig` becomes optional, and the installer classifies what it is given:
|
|
412
|
+
|
|
413
|
+
| Envelope | Outcome |
|
|
414
|
+
|---|---|
|
|
415
|
+
| Valid signature from `TRUSTED_KEYS` | Installs. Current behaviour, unchanged. |
|
|
416
|
+
| Signature present but invalid | **Refused**, loudly. A broken signature is a tampered bundle, never an unsigned one. |
|
|
417
|
+
| No signature, `requireSignedPlugins: false` (default) | Installs **after an explicit confirmation** naming the plugin and stating that its code will run on this server. Recorded in the audit log as `install:unsigned`. |
|
|
418
|
+
| No signature, `requireSignedPlugins: true` | Refused. |
|
|
419
|
+
|
|
420
|
+
`requireSignedPlugins` is a new key in `config/server.json`. Default `false`, because a CMS whose
|
|
421
|
+
own plugin scaffolder (`scripts/create-plugin.js`, `POST /plugins/scaffold`) produces unsigned
|
|
422
|
+
plugins cannot refuse unsigned plugins without refusing its own. Fleet sites should set it `true`,
|
|
423
|
+
since everything they receive arrives by push-copy and nothing legitimate is ever uploaded there.
|
|
424
|
+
|
|
425
|
+
The confirmation is not security theatre and should not be dressed up as security: uploading a
|
|
426
|
+
plugin is uploading server-side code, exactly like `plugins.develop` already permits through the
|
|
427
|
+
file editor. The prompt exists so that *running someone else's code* is a decision rather than a
|
|
428
|
+
side effect of dragging a file onto a page.
|
|
429
|
+
|
|
430
|
+
### Why not just a zip of the directory
|
|
431
|
+
|
|
432
|
+
Because the three paths would then diverge. One envelope with an optional signature means the paid
|
|
433
|
+
download, the catalogue bundle and a hand-made export all enter through `installPlugin()`, get the
|
|
434
|
+
same traversal guard, the same manifest validation, the same cleanup on failure and the same audit
|
|
435
|
+
record. A second "just unzip it" path would be the one that skips a check.
|
|
436
|
+
|
|
437
|
+
## Commercial terms
|
|
438
|
+
|
|
439
|
+
Two things are being sold, and they are not the same thing sold twice.
|
|
440
|
+
|
|
441
|
+
| | **Managed site** (fleet) | **Unmanaged site** (self-hosted) |
|
|
442
|
+
|---|---|---|
|
|
443
|
+
| Model | **Rental**, per month | **Bought outright**, one hefty payment |
|
|
444
|
+
| Entitlement | `model: "rental"`, `expiresAt` = end of paid month | `model: "perpetual"`, `expiresAt: null` |
|
|
445
|
+
| Included | Support and updates while renting | **12 months** support and updates |
|
|
446
|
+
| Then | Stops on non-payment, after grace | **Keeps running forever**; ongoing renewal buys further support and updates |
|
|
447
|
+
| Delivery | Push-copy, path A | Download envelope, path B |
|
|
448
|
+
| Enforcement | Manager owns the filesystem; `rescindPlugin()` already does this | Entitlement file, verified offline |
|
|
449
|
+
|
|
450
|
+
The asymmetry is deliberate and should be said out loud to customers: on a managed site we carry the
|
|
451
|
+
hosting, the updates, the restarts and the backups, so the plugin is rented alongside them. On an
|
|
452
|
+
unmanaged site the customer carries all of that, gets no operational help, and pays once - heavily,
|
|
453
|
+
because a paid plugin is months of work and a one-off sale has to stand on its own.
|
|
454
|
+
|
|
455
|
+
### Where terms live
|
|
456
|
+
|
|
457
|
+
**`plugins/site-manager/data/plugin-terms.json`**, edited in the admin. Not in the manifest, not in
|
|
458
|
+
git, not in the catalogue build. Changing a price must be a form field and a save, never a release.
|
|
459
|
+
|
|
460
|
+
```json
|
|
461
|
+
{
|
|
462
|
+
"seo-pro": {
|
|
463
|
+
"managed": { "available": true, "monthly": 1200, "minimumTermMonths": 1 },
|
|
464
|
+
"unmanaged": { "available": true, "licence": 89900, "supportMonths": 12,
|
|
465
|
+
"renewal": 17900, "renewalPeriodMonths": 12 },
|
|
466
|
+
"currency": "GBP",
|
|
467
|
+
"notes": "Shown on the purchase screen",
|
|
468
|
+
"updatedAt": "2026-09-22T00:00:00.000Z",
|
|
469
|
+
"updatedBy": "darryl"
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
All amounts are **integer minor units** (pence), never floats. `available: false` withdraws one
|
|
475
|
+
model without withdrawing the plugin - a plugin can be fleet-only, or sale-only, by flipping a
|
|
476
|
+
boolean rather than by being removed from the catalogue.
|
|
477
|
+
|
|
478
|
+
This file is also what Site Manager merges into the catalogue it serves, and what gets published
|
|
479
|
+
alongside the static catalogue as `terms.json`. Two files, two lifecycles: `catalogue.json` is
|
|
480
|
+
rebuilt when a plugin is released, `terms.json` is rewritten whenever a price changes. A price
|
|
481
|
+
change is therefore instant and touches no code.
|
|
482
|
+
|
|
483
|
+
### Editing terms
|
|
484
|
+
|
|
485
|
+
New Site Manager view, **Plugin Terms**, sitting beside Plugin Store and Licences. Per plugin: both
|
|
486
|
+
models, both prices, support and renewal periods, availability toggles, free-text notes. It is a
|
|
487
|
+
table of editable rows, not a wizard - this is a screen for changing a number.
|
|
488
|
+
|
|
489
|
+
Two rules the editor enforces:
|
|
490
|
+
|
|
491
|
+
1. **A terms change never alters an issued entitlement.** What someone paid, and what they were
|
|
492
|
+
promised, is captured in the entitlement and the ledger at issue time. Raising the price of SEO
|
|
493
|
+
Pro tomorrow does not shorten anybody's support window or re-rate a live rental. The new figure
|
|
494
|
+
applies to new purchases and to renewals quoted after the change.
|
|
495
|
+
2. **Renewal quotes the terms current at renewal**, which is the honest version of the same rule and
|
|
496
|
+
should be stated on the purchase screen so nobody is surprised a year later.
|
|
497
|
+
|
|
498
|
+
### Tier register
|
|
499
|
+
|
|
500
|
+
The assignment, as decided on 2026-09-22. `tier` in the manifest is the machine-readable half of
|
|
501
|
+
this table; the table is the record of *why*.
|
|
502
|
+
|
|
503
|
+
**Free - the Tools tier, given away without conditions**
|
|
504
|
+
|
|
505
|
+
| Plugin | LOC | Note |
|
|
506
|
+
|---|---|---|
|
|
507
|
+
| `blog` | 1,878 | **Decided free.** The largest thing in the giveaway pile, and a CMS without a blog looks broken. |
|
|
508
|
+
| `contacts` | 1,427 | Enabled on 8/8 sites. Standard PIM. |
|
|
509
|
+
| `analytics` | 678 | Core plugin already, cannot be switched off. Sold separately as `analytics-pro`. |
|
|
510
|
+
| `todo` | 452 | Lightweight essential. |
|
|
511
|
+
| `notes` | 412 | Lightweight essential. |
|
|
512
|
+
| `theme-switcher` | 101 | Cosmetic, Domma-branded. |
|
|
513
|
+
| `mail-reader` | 74 | Read-only IMAP. The substance is `_lib/mail`, which stays free - `email-pro` is the paid half. |
|
|
514
|
+
| `demo-viewer` | 40 | Marketing shortcode for Domma itself. |
|
|
515
|
+
|
|
516
|
+
Roughly 5.3k LOC given away. Only `analytics`, `contacts` and `demo-viewer` are widely enabled
|
|
517
|
+
today; the rest are there for credibility rather than uptake, which is the correct job for a free
|
|
518
|
+
tier and the reason none of them need a terms entry.
|
|
519
|
+
|
|
520
|
+
**Paid**
|
|
521
|
+
|
|
522
|
+
| Plugin | LOC | Managed | Unmanaged | Note |
|
|
523
|
+
|---|---|---|---|---|
|
|
524
|
+
| `invoice` | 4,999 | rental | perpetual | **Decided paid, licence or rent.** Largest product plugin. Currently free on npm - see [Extracting a plugin that shipped free](#extracting-a-plugin-that-shipped-free). |
|
|
525
|
+
| `email-pro` | 4,275 | rental | perpetual | Supersedes `mail-reader`. Read surface stays free in `_lib/mail`. |
|
|
526
|
+
| `docs` | 2,253 | rental | perpetual | Finished, enabled on zero sites. |
|
|
527
|
+
| `calendar` | 1,791 | rental | perpetual | Finished, enabled on zero sites. |
|
|
528
|
+
| `surveys` | 1,657 | rental | perpetual | **Decided paid; the free copy is extracted**, same as `invoice`. Slug stays `surveys`. |
|
|
529
|
+
| `job-board` | 1,221 | rental | perpetual | Finished, never sold. |
|
|
530
|
+
| `messages` | 1,118 | rental | perpetual | Enabled on 2 sites. |
|
|
531
|
+
| `garage` | 1,038 | rental | perpetual | UK-vertical, wraps DVLA. Finished, enabled on zero sites. |
|
|
532
|
+
| `data-transfer` | 1,003 | rental | perpetual | 5 active licences. |
|
|
533
|
+
| `theme-roller` | 637 | rental | perpetual | Finished, never sold. |
|
|
534
|
+
| `analytics-pro` | 476 | rental | perpetual | 4 active licences. |
|
|
535
|
+
| `mongodb-adapter` | 40 | **rental only** | **not available** | **Decided: paid upgrade for managed sites, monthly.** See below. |
|
|
536
|
+
| `seo-pro` | 15 | - | - | **Withdrawn. Licences revoked, slug reserved.** See [Withdrawing seo-pro](#withdrawing-seo-pro-and-reserving-a-slug). |
|
|
537
|
+
|
|
538
|
+
**Never in the marketplace, at any tier**
|
|
539
|
+
|
|
540
|
+
`site-manager` (29,923) and `customer-registration` (1,768) run the business rather than a customer's
|
|
541
|
+
site. `site-tweaks` (10) is a marketing injection carrier for our own site. None get a `tier`, a
|
|
542
|
+
terms entry or a catalogue entry.
|
|
543
|
+
|
|
544
|
+
**Site-local - stays with its site, decided 2026-09-22**
|
|
545
|
+
|
|
546
|
+
| Plugin | Site | Disposition |
|
|
547
|
+
|---|---|---|
|
|
548
|
+
| `reactive-showcase` | `reactive` | Stays in the site's repo. Loads `domma-reactive` and provides `[srcview]`, which renders the source behind each live demo. Site-specific by nature. Worth doing separately: stop vendoring `domma-reactive.esm.js` and take it from npm, which removes 8k lines from the site. |
|
|
549
|
+
| `retro64-games` | `retro-64` | Stays in the site's repo. Note it *is* structurally a plugin - four routes, an admin view and a per-game JSON store - so it stays one; it is simply never a marketplace plugin. |
|
|
550
|
+
|
|
551
|
+
**Becoming core rather than a plugin**
|
|
552
|
+
|
|
553
|
+
| Plugin | Disposition |
|
|
554
|
+
|---|---|
|
|
555
|
+
| `cache-autoflush` | **Promote into core.** See [Promoting a plugin into core](#promoting-a-plugin-into-core). |
|
|
556
|
+
| `retro64-cors` | **Promote into core as config**, then delete. Core registers `@fastify/cors` once with `serverConfig.cors`, which is global, so it cannot express "`Access-Control-Allow-Origin: *` on GET and HEAD under one path prefix" - which is all this plugin does, for `/public/retro64/`. A `cors.publicPrefixes: []` key in `config/server.json` would cover it in about fifteen lines and serve any site that wants its own assets hot-linkable. |
|
|
557
|
+
|
|
558
|
+
**Free tier, but nothing built yet**
|
|
559
|
+
|
|
560
|
+
`shopping-cart` (50 LOC, `domma-cms-site`) is **untouched scaffolder output** - every route commented
|
|
561
|
+
out, `onEnable` empty, only the description written. Free is the right intention, but there is no
|
|
562
|
+
asset to import: the slug goes in `reserved.json` until something exists behind it, exactly as
|
|
563
|
+
`seo-pro` does. Separately, `domma-cms-site` needs to become a git repo.
|
|
564
|
+
|
|
565
|
+
**To be deleted rather than tiered**
|
|
566
|
+
|
|
567
|
+
`form-builder` - 0 LOC, empty `data/` directories on all eight sites, code long gone since forms
|
|
568
|
+
moved into core. `plugins/site-search/` - empty residue the migration could not remove for want of a
|
|
569
|
+
manifest. Both still appear in `KNOWN_HUES`, which this design deletes anyway.
|
|
570
|
+
|
|
571
|
+
### Managed-only plugins
|
|
572
|
+
|
|
573
|
+
`mongodb-adapter` is the first plugin available under one model only, and the reason is technical
|
|
574
|
+
rather than commercial. The plugin is a 40-line shim that reads a `config/connections.json`
|
|
575
|
+
**written by the manager during provisioning** (`mongoProvisioner.js`) and decorates Fastify with
|
|
576
|
+
it. On an unmanaged site nothing writes that file, so the plugin has nothing to read and would sell
|
|
577
|
+
a customer a no-op.
|
|
578
|
+
|
|
579
|
+
What a self-hosted customer can do instead, for free: write `connections.json` themselves and
|
|
580
|
+
declare `"storage": {"adapter": "mongodb"}` in a schema. Core already resolves that
|
|
581
|
+
(`adapterRegistry.resolveMongoAdapter()`), and `mongodb` is in `optionalDependencies`, so Mongo
|
|
582
|
+
support is **not** withheld from the free tier at all.
|
|
583
|
+
|
|
584
|
+
Which makes the managed subscription honest to describe: it does not sell the adapter, it sells
|
|
585
|
+
provisioning, credentials, connection management and the backups that go with them. Anyone prepared
|
|
586
|
+
to run their own Mongo already has the capability. The terms editor expresses this as
|
|
587
|
+
`unmanaged.available: false`, and the purchase screen should say why rather than leaving a customer
|
|
588
|
+
to wonder what they are being denied.
|
|
589
|
+
|
|
590
|
+
This is a pattern, not a one-off: any plugin whose value is a service we operate belongs in the
|
|
591
|
+
managed column only.
|
|
592
|
+
|
|
593
|
+
### Extracting a plugin that shipped free
|
|
594
|
+
|
|
595
|
+
`invoice` is paid from now on, and it is currently in `domma-cms/plugins/`, in the npm tarball, and
|
|
596
|
+
enabled on four sites. `garage` went through this in 2026-04 and the steps are the same:
|
|
597
|
+
|
|
598
|
+
1. **Move it to the monorepo** with `tier: "paid"` and `closedSource: true`. Two guards switch
|
|
599
|
+
themselves on as a consequence: the source editor refuses it (`pluginFiles.isClosedSource`) and
|
|
600
|
+
path C refuses to export it.
|
|
601
|
+
2. **Remove it from `domma-cms/plugins/`**, so it leaves the next npm release and the next
|
|
602
|
+
`_publish/` build. Whether git history is rewritten is a separate call - the 2026-04-08 plan did
|
|
603
|
+
rewrite for `garage`; for `invoice` the code is already public in published tarballs, so a
|
|
604
|
+
rewrite buys less.
|
|
605
|
+
3. **Grandfather the four live sites.** All four are managed fleet sites, so
|
|
606
|
+
`entitlements.grantPlugin()` covers it exactly as it stands: files are already in place, a rental
|
|
607
|
+
licence is issued, nothing restarts, nobody notices. Do this **before** step 2, so there is no
|
|
608
|
+
window where a site holds a paid plugin with no licence record.
|
|
609
|
+
4. **Accept that published tarballs stay published.** Anyone who already ran `npm install` has the
|
|
610
|
+
version they have, forever. Only new versions are withheld. Saying this plainly is better than
|
|
611
|
+
implying an extraction is a recall.
|
|
612
|
+
|
|
613
|
+
Nothing here is specific to `invoice`. **`surveys` takes the same four steps**, with one difference
|
|
614
|
+
in its favour and one against:
|
|
615
|
+
|
|
616
|
+
- **In its favour:** it is enabled on zero sites, so step 3 grandfathers two licence records rather
|
|
617
|
+
than four running installations. Nothing to disturb.
|
|
618
|
+
- **Against:** it is *present* on all eight sites, copied there by the scaffolder (finding #10), and
|
|
619
|
+
it is the plugin whose duplicate-slug collision blocks catalogue generation. Extraction resolves
|
|
620
|
+
both at once, which is why it is the cleaner of the two options that were on the table - the
|
|
621
|
+
alternative, renaming the paid copy `surveys-pro`, would have left a permanently lesser `surveys`
|
|
622
|
+
on npm and two plugins to maintain.
|
|
623
|
+
|
|
624
|
+
### Stop distributing paid plugins by default
|
|
625
|
+
|
|
626
|
+
`SKIP_PLUGINS` in `siteScaffolder.js:277` is replaced by a tier check:
|
|
627
|
+
|
|
628
|
+
```js
|
|
629
|
+
// Free-tier plugins are seeded into a new site; paid ones arrive by grant.
|
|
630
|
+
if (manifest.tier === 'paid') continue;
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
Read from the manifest that is being copied anyway, so a new paid plugin is excluded the day it is
|
|
634
|
+
written rather than the day somebody remembers the list. The manager-specific exclusions
|
|
635
|
+
(`site-manager`, `customer-registration`) stay, because they are not a tier, they are not a
|
|
636
|
+
customer's software at all.
|
|
637
|
+
|
|
638
|
+
Two consequences worth stating:
|
|
639
|
+
|
|
640
|
+
1. **A new site starts clean:** free plugins seeded, nothing else on disk. A paid plugin appears when
|
|
641
|
+
it is granted, which is when `pluginDistributor.activate()` copies it.
|
|
642
|
+
2. **Existing sites carry strays** - six slugs' worth, unlicensed and disabled. They should be
|
|
643
|
+
**removed**, not left to report `"unlicensed"` forever. Removal is safe because a grant copies the
|
|
644
|
+
files back; a site that later buys `docs` gets `docs` then.
|
|
645
|
+
|
|
646
|
+
## The plugin builder is free, permanently
|
|
647
|
+
|
|
648
|
+
The builder is the thing that makes a marketplace worth having, so it is never gated, never metered
|
|
649
|
+
and never moved to the paid tier. It already exists and already ships free:
|
|
650
|
+
|
|
651
|
+
| Surface | Where |
|
|
652
|
+
|---|---|
|
|
653
|
+
| Scaffolder (admin) | `POST /api/plugins/scaffold` → `pluginScaffold.js`, instantiating `plugins/_template/` |
|
|
654
|
+
| Scaffolder (CLI) | `scripts/create-plugin.js` |
|
|
655
|
+
| Code editor | `#/plugins/code/:name` (`admin/js/views/plugin-code.js`), files API under `plugins.develop` |
|
|
656
|
+
| Shared helpers | `plugins/_lib/` - dataStore, mail, admin UI, schemaSync |
|
|
657
|
+
| Documentation | `docs/plugin-development.md`, `plugins/CLAUDE.md` |
|
|
658
|
+
|
|
659
|
+
Commitments this design makes about them:
|
|
660
|
+
|
|
661
|
+
- **No entitlement check ever runs against a plugin the user wrote.** The gate keys on
|
|
662
|
+
`closedSource: true`, which is a flag *we* set on *our* paid plugins. A user's own plugin has no
|
|
663
|
+
entitlement, needs none, and must never be asked for one.
|
|
664
|
+
- **`_template` and `_lib` stay in the free npm package**, so `npm install domma-cms` is a complete
|
|
665
|
+
plugin development environment with no account, no key and no network.
|
|
666
|
+
- **`requireSignedPlugins` defaults to `false`** partly for this reason: a CMS whose own scaffolder
|
|
667
|
+
emits unsigned plugins cannot refuse unsigned plugins without refusing the user's own work.
|
|
668
|
+
|
|
669
|
+
### Withdrawing seo-pro, and reserving a slug
|
|
670
|
+
|
|
671
|
+
`seo-pro` is fourteen lines whose own header calls it a sample. It answers
|
|
672
|
+
`GET /api/plugins/seo-pro/meta` with `{title: null, description: null, ogImage: null}` and has three
|
|
673
|
+
active licences against it. It is withdrawn: the licences are revoked and the slug is held back until
|
|
674
|
+
there is something behind it.
|
|
675
|
+
|
|
676
|
+
Two facts make this safe rather than a customer-facing change:
|
|
677
|
+
|
|
678
|
+
1. **Nothing calls it.** No site's content or public JS references `seo-pro` anywhere across the
|
|
679
|
+
eight sites. A plugin returning hardcoded nulls could not have acquired a dependent.
|
|
680
|
+
2. **The licences were never sold.** All three are `type: "generated"`, issued automatically by
|
|
681
|
+
`proManager.enable()` when the Pro bundle was switched on for each site - the activation dates
|
|
682
|
+
match each site's provisioning. Nobody bought `seo-pro`; it arrived inside a bundle.
|
|
683
|
+
|
|
684
|
+
**The step that makes or breaks this:** `seo-pro` is in the **default Pro bundle**
|
|
685
|
+
(`config.js:55` - `proBundle: ['mongodb-adapter', 'analytics-pro', 'seo-pro', 'data-transfer']`).
|
|
686
|
+
Revoking the three licences without removing it from that array means the next Pro enable on any site
|
|
687
|
+
re-issues one. The order is therefore:
|
|
688
|
+
|
|
689
|
+
1. Remove `seo-pro` from `proBundle`.
|
|
690
|
+
2. `entitlements.rescindPlugin()` for each of the three sites - which deactivates, revokes and
|
|
691
|
+
restarts through `processManager`. **Not** by hand: a site stopped outside `processManager` does
|
|
692
|
+
not come back.
|
|
693
|
+
3. Delete the files from those three sites and from `paid-plugins/`.
|
|
694
|
+
4. Do not import it into the monorepo. The slug becomes reserved.
|
|
695
|
+
|
|
696
|
+
### Reserved slugs
|
|
697
|
+
|
|
698
|
+
A reserved slug is one that is neither free nor paid: it has no tier, no terms entry, no catalogue
|
|
699
|
+
entry, and **cannot be granted**. It exists only so that nothing else takes the name and so the
|
|
700
|
+
eventual real plugin inherits it.
|
|
701
|
+
|
|
702
|
+
`dcms-marketplace/reserved.json` holds the list with a reason each, and `build-catalogue.js` refuses
|
|
703
|
+
to emit a bundle for any slug in it. That refusal is the point: a stub cannot reach a catalogue by
|
|
704
|
+
somebody adding a directory and forgetting why the name was being kept.
|
|
705
|
+
|
|
706
|
+
```json
|
|
707
|
+
{
|
|
708
|
+
"seo-pro": "Withdrawn 2026-09-22 - was a sample stub with three bundle-issued licences. Slug held for a real SEO plugin.",
|
|
709
|
+
"form-builder": "Retired when forms moved into core. Slug held to avoid resurrecting a dead name.",
|
|
710
|
+
"site-search": "Retired into server/services/search.js. Slug held for the same reason."
|
|
711
|
+
}
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
### Packaging belongs with the terms, not in code
|
|
715
|
+
|
|
716
|
+
`proBundle` is a hand-maintained array in `config.js`, and it is the fourth such list this design
|
|
717
|
+
retires - after `SKIP_PLUGINS`, `KNOWN_HUES` and the README table. It is also, unlike those, purely
|
|
718
|
+
**commercial** configuration: which plugins a customer gets for the Pro price is the same kind of
|
|
719
|
+
decision as what the Pro price is.
|
|
720
|
+
|
|
721
|
+
So it moves into `plugin-terms.json` as a `bundles` block and becomes editable in the Plugin Terms
|
|
722
|
+
view:
|
|
723
|
+
|
|
724
|
+
```json
|
|
725
|
+
"bundles": {
|
|
726
|
+
"pro": { "label": "Pro", "plugins": ["mongodb-adapter", "analytics-pro", "data-transfer"],
|
|
727
|
+
"monthly": 2900 }
|
|
728
|
+
}
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
`resolveBundle()` reads it from there instead of from `DEFAULT_PRO_BUNDLE`, which means withdrawing a
|
|
732
|
+
plugin from the bundle - exactly what `seo-pro` needs - stops being a code change and a fleet roll.
|
|
733
|
+
The `mongodb-adapter` special case at `proManager.js:120`, which skips it when no master Mongo URI is
|
|
734
|
+
configured, stays as it is: it is a capability check, not packaging, and it agrees with the
|
|
735
|
+
managed-only decision.
|
|
736
|
+
|
|
737
|
+
### Promoting a plugin into core
|
|
738
|
+
|
|
739
|
+
`cache-autoflush` should not be a plugin, and the CMS has already done this once: `site-search`
|
|
740
|
+
became `server/services/search.js`, with `search-migration.js` lifting existing sites off the plugin
|
|
741
|
+
at startup. That is the pattern to copy.
|
|
742
|
+
|
|
743
|
+
**Why it belongs in core**, in its own words: the CMS invalidates the response cache on API and admin
|
|
744
|
+
saves (`content.js` → `cache.invalidateTags`), but changes made *outside* the API - direct file
|
|
745
|
+
edits, git or rsync deploys, scripted updates - never fire that hook, and the production
|
|
746
|
+
`MemoryDriver` has no TTL. So an edited page is served stale indefinitely. It has to run in-process
|
|
747
|
+
to reach the live in-memory cache, which is exactly why an external tool cannot fix it.
|
|
748
|
+
|
|
749
|
+
This is a fleet-wide papercut, not one site's quirk: every fleet site runs `NODE_ENV=production`, so
|
|
750
|
+
every one of them serves stale HTML after a hand-edit until it is restarted. One site happens to have
|
|
751
|
+
the fix installed.
|
|
752
|
+
|
|
753
|
+
**Shape in core:**
|
|
754
|
+
|
|
755
|
+
- Lives with the cache it serves, in `server/services/cache/`, started from `server.js` alongside the
|
|
756
|
+
cache itself.
|
|
757
|
+
- Configured in `config/cache.json`, which already exists and is already the owner's file:
|
|
758
|
+
`"autoflush": { "enabled": true, "debounceMs": 300, "watch": [...] }`.
|
|
759
|
+
- Default **on in production, off in development**, matching how the cache itself already defaults -
|
|
760
|
+
in development nothing is cached, so there is nothing to flush.
|
|
761
|
+
- Keeps the whole-cache clear rather than per-page invalidation. A block, CSS or menu change can
|
|
762
|
+
affect many pages and entries repopulate lazily, so the precision is not worth the bookkeeping.
|
|
763
|
+
- A retirement migration modelled on `search-migration.js`: settings out of `plugins.json`, the
|
|
764
|
+
`plugins.json` entry removed, and `plugins/cache-autoflush/` deleted **only if it is the bundled
|
|
765
|
+
one** (manifest name and author both match). A directory of that name carrying somebody else's work
|
|
766
|
+
is left alone, and the migration must run before the plugin loader, or the site briefly runs two
|
|
767
|
+
watchers.
|
|
768
|
+
|
|
769
|
+
The same question should be asked of any plugin nobody may reasonably switch off. `CORE_PLUGINS` in
|
|
770
|
+
`plugins.js` documents itself as a waiting room rather than a destination for exactly this reason.
|
|
771
|
+
|
|
772
|
+
### Submitting a plugin to the marketplace
|
|
773
|
+
|
|
774
|
+
Path C is the submission pipeline, and no new machinery is needed for it. A user builds a plugin
|
|
775
|
+
with the free builder, exports it as one `.dcmsplugin` file, and sends that file. We review it,
|
|
776
|
+
sign it with the marketplace key, and add it to the monorepo as any other plugin.
|
|
777
|
+
|
|
778
|
+
That the export format and the distribution format are the same file is the point: a submission is
|
|
779
|
+
testable by installing it exactly the way a customer would, before any decision is made about it.
|
|
780
|
+
|
|
781
|
+
Author terms - revenue share, attribution, support obligations, whether a third-party plugin can be
|
|
782
|
+
paid at all - are **out of scope here** and listed in Open questions. Accepting third-party *paid*
|
|
783
|
+
plugins in particular would change the signing model, because we would be signing code we did not
|
|
784
|
+
write.
|
|
785
|
+
|
|
786
|
+
## Signing keys
|
|
787
|
+
|
|
788
|
+
`manager-2026` is gone. It is replaced by **`marketplace-1`**, and the old entry is **deleted from
|
|
789
|
+
`TRUSTED_KEYS` rather than kept for continuity**.
|
|
790
|
+
|
|
791
|
+
Deleting it is the security decision, and it is worth being explicit about why. A private key we
|
|
792
|
+
cannot find is not a key we know to be destroyed - it is a key whose whereabouts are unknown.
|
|
793
|
+
Leaving its public half in `TRUSTED_KEYS` would mean that anyone who does have it can sign a bundle
|
|
794
|
+
that every Domma CMS accepts as ours, forever. Nothing has ever been signed with it, so there is not
|
|
795
|
+
a single artifact to keep compatible. Removing it costs nothing and closes that door.
|
|
796
|
+
|
|
797
|
+
### Naming
|
|
798
|
+
|
|
799
|
+
`marketplace-<n>`, a monotonic counter, not a year. The first key is `marketplace-1`. A year in the
|
|
800
|
+
id reads as an expiry date it does not have, and the key being replaced today would have to be
|
|
801
|
+
called a 2026 key too, which is exactly the confusion worth avoiding.
|
|
802
|
+
|
|
803
|
+
### Algorithm - Ed25519
|
|
804
|
+
|
|
805
|
+
**Decided 2026-09-22.** Rotation was the one free moment to change algorithm, since no existing
|
|
806
|
+
signature had to stay valid.
|
|
807
|
+
|
|
808
|
+
```js
|
|
809
|
+
// Ed25519 passes null as the digest; the algorithm is implied by the key.
|
|
810
|
+
const valid = crypto.verify(null, tarball, pubKeyPem, signatureBuffer);
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
That is the entire change to `pluginInstaller.js` - one argument. Generation and signing:
|
|
814
|
+
|
|
815
|
+
```sh
|
|
816
|
+
openssl genpkey -algorithm ed25519 -out marketplace-1.pem # offline, then encrypt it
|
|
817
|
+
openssl pkey -in marketplace-1.pem -pubout -out marketplace-1.pub # commit this half
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
Measured on Node 22.22.2 rather than assumed:
|
|
821
|
+
|
|
822
|
+
| | Ed25519 | RSA-2048 (retired key) |
|
|
823
|
+
|---|---|---|
|
|
824
|
+
| Signature | **64 bytes** | 256 bytes |
|
|
825
|
+
| Public key PEM | **3 lines** | 9 lines |
|
|
826
|
+
| Digest argument | none - implied by the key | `'sha256'`, chosen by the caller |
|
|
827
|
+
|
|
828
|
+
**The mismatch fails closed**, which is the part worth knowing: calling
|
|
829
|
+
`crypto.verify('sha256', …)` against an Ed25519 key throws `ERR_OSSL_INVALID_DIGEST`. So if the
|
|
830
|
+
digest argument is ever left behind during the edit, installs break loudly instead of silently
|
|
831
|
+
accepting something. A verifier that quietly degraded would be the worst outcome available here, and
|
|
832
|
+
the runtime refuses to provide it.
|
|
833
|
+
|
|
834
|
+
`TRUSTED_KEYS` therefore holds Ed25519 keys only, and the verifier handles exactly one algorithm.
|
|
835
|
+
Accepting either would invite a downgrade, and with `manager-2026` deleted there is no legacy to
|
|
836
|
+
accommodate.
|
|
837
|
+
|
|
838
|
+
Recorded alternative: RSA-4096 would have needed no code change at all, but carried a larger key, a
|
|
839
|
+
caller-chosen digest, and no upside. RSA-2048 was not an option for a key meant to last.
|
|
840
|
+
|
|
841
|
+
### Custody
|
|
842
|
+
|
|
843
|
+
**What actually happened, 2026-09-22:** `marketplace-1` was generated **on the manager host**, as a
|
|
844
|
+
deliberate call, against the rule below. Recorded rather than quietly contradicted - a custody
|
|
845
|
+
document that does not match reality protects nothing.
|
|
846
|
+
|
|
847
|
+
The consequence is bounded but real: the private half has existed in the filesystem of an
|
|
848
|
+
internet-facing box, and in any snapshot or backup taken since. It lives at `~/.domma-keys/` (0700,
|
|
849
|
+
key 0600), outside every repository, and `*.pem` is gitignored in the marketplace repo so it cannot
|
|
850
|
+
be committed by reflex. If that box is ever suspected, this key is suspect with it, and the rotation
|
|
851
|
+
procedure below is the answer.
|
|
852
|
+
|
|
853
|
+
The rule, for the next key:
|
|
854
|
+
|
|
855
|
+
- Generated on a machine that is not the manager host, and **never copied onto it**. The manager
|
|
856
|
+
serves bundles; it does not sign them.
|
|
857
|
+
- Private half encrypted with a passphrase and stored in **at least two places** that are not the
|
|
858
|
+
same disk and not a git repository. This is the failure being recovered from today.
|
|
859
|
+
- Signing happens where the key lives, as part of `make catalogue`. The output that travels is the
|
|
860
|
+
signature, never the key.
|
|
861
|
+
- The public half is committed: it ships inside `domma-cms` in `TRUSTED_KEYS`, and a copy lives in
|
|
862
|
+
`dcms-marketplace/keys/` so the build can verify its own output.
|
|
863
|
+
|
|
864
|
+
### Manager keys: entitlements only (2026-10-01)
|
|
865
|
+
|
|
866
|
+
A manager mints licences as it grants them, so its signing key has to live **online, on the manager
|
|
867
|
+
host** - the opposite of the rule above, and unavoidable for that job. svr-2 (aurelius, a shared
|
|
868
|
+
box) got its own key, `svr-2-1`, generated there on 2026-10-01 and never copied off it, instead of a
|
|
869
|
+
copy of `marketplace-1`.
|
|
870
|
+
|
|
871
|
+
What makes that acceptable is that `TRUSTED_KEYS` now says what each key may sign: `bundle` (plugin
|
|
872
|
+
code, which every CMS installs) and/or `entitlement` (a licence for a plugin a site already has).
|
|
873
|
+
`svr-2-1` is trusted for **entitlements only**: `installPlugin` refuses it by name, exactly like an
|
|
874
|
+
unknown key. If it leaks, the damage is forged licences, never code every CMS would run, and it is
|
|
875
|
+
withdrawn by a release without touching `marketplace-1` or anything it signed. A manager signs as
|
|
876
|
+
its key file's name (site-manager's `keyIdForKeyFile`), so the id and the key cannot disagree.
|
|
877
|
+
|
|
878
|
+
### Rotating again
|
|
879
|
+
|
|
880
|
+
Losing a key is survivable, which this rotation demonstrates. Because a CMS pins the keyIds it
|
|
881
|
+
trusts, and the catalogue records `minCmsVersion`, the procedure is:
|
|
882
|
+
|
|
883
|
+
1. Generate `marketplace-<n+1>` offline.
|
|
884
|
+
2. Add it to `TRUSTED_KEYS` **alongside** the current key and ship that CMS release.
|
|
885
|
+
3. Re-sign the catalogue with the new key once enough of the fleet is updated.
|
|
886
|
+
4. Remove the old key in a later release.
|
|
887
|
+
|
|
888
|
+
The overlap in steps 2 and 3 is what makes a planned rotation invisible to customers. It is also
|
|
889
|
+
precisely what a *lost* key does not get: today's rotation has no overlap period, and it does not
|
|
890
|
+
need one only because no signed artifact exists.
|
|
891
|
+
|
|
892
|
+
### Consequence for existing installs
|
|
893
|
+
|
|
894
|
+
`marketplace-1` is trusted only by CMS versions that ship it. Anything older trusts `manager-2026`,
|
|
895
|
+
which will never sign anything. That is not a regression - no CMS anywhere has ever successfully
|
|
896
|
+
installed a signed bundle - but it does mean the marketplace's real floor is the release that
|
|
897
|
+
carries the new key, which is what `minCmsVersion` on every catalogue entry exists to express.
|
|
898
|
+
|
|
899
|
+
## Why Site Manager does not host the public catalogue
|
|
900
|
+
|
|
901
|
+
Site Manager is the right dispenser and the wrong host.
|
|
902
|
+
|
|
903
|
+
**Right dispenser:** it already holds the paid catalogue, the licence ledger, the grant/rescind
|
|
904
|
+
logic, the Plugin Store UI, and the process control to restart a child. Serving the fleet endpoints
|
|
905
|
+
and producing a download envelope is a small addition to something already built.
|
|
906
|
+
|
|
907
|
+
**Wrong host:** it is the fleet's reverse proxy, routing every customer domain by `Host` header,
|
|
908
|
+
and in the same process it holds the licence ledger, certificate manager, Mongo provisioner, site
|
|
909
|
+
users and data manager. Exposing it to the public internet for anonymous catalogue browsing widens
|
|
910
|
+
the blast radius of the most privileged process we run, for no gain - the catalogue is static
|
|
911
|
+
files. It also lives in a repo the updater half-overwrites, which is a poor foundation for a
|
|
912
|
+
public service.
|
|
913
|
+
|
|
914
|
+
So: static artifacts serve the public, Site Manager serves the fleet and mints downloads.
|
|
915
|
+
|
|
916
|
+
## Changes by file
|
|
917
|
+
|
|
918
|
+
### `domma-cms` (core)
|
|
919
|
+
|
|
920
|
+
| File | Change |
|
|
921
|
+
|---|---|
|
|
922
|
+
| `server/services/pluginEntitlement.js` | New. Verify, classify, report. |
|
|
923
|
+
| `server/services/plugins.js` | Gate `closedSource` plugins on entitlement in `registerPlugins()`; surface state through `getLoadedPlugins()`. |
|
|
924
|
+
| `server/services/pluginInstaller.js` | Accept `entitlement`; add the upgrade path (below); make `plugin.sig` optional; **`crypto.verify(null, …)` for Ed25519**; `TRUSTED_KEYS` gains `marketplace-1` and loses `manager-2026`. |
|
|
925
|
+
| `server/routes/api/plugins.js` | `POST /plugins/install-upload`, `GET /plugins/:name/export`, `POST /plugins/entitlement`; add `entitlement` to the `GET /plugins` payload beside the existing `licence` and `supersededBy`. |
|
|
926
|
+
| `admin/js/views/plugins.js` | Install-from-file control and the unsigned-import confirmation; an Export action per card (absent on `closedSource`); entitlement state on the card; make the Marketplace tab real. |
|
|
927
|
+
| `config/server.json` | New `requireSignedPlugins` (default `false`), merged on update. |
|
|
928
|
+
| `admin/js/lib/plugin-accent.js` | Delete `KNOWN_HUES`; keep the hash fallback. |
|
|
929
|
+
| `README.md` | Bundled Plugins table generated, or removed in favour of a link. |
|
|
930
|
+
|
|
931
|
+
### `domma-cms-manager` (Site Manager plugin)
|
|
932
|
+
|
|
933
|
+
| Route | Purpose |
|
|
934
|
+
|---|---|
|
|
935
|
+
| `GET /api/plugins/catalogue` | **Built.** Serve the catalogue to a fleet child, joined with terms and this site's licences. |
|
|
936
|
+
| `GET /api/plugins/install/:slug/:version` | **Built.** Serve the triple plus a rental entitlement minted for the calling site. |
|
|
937
|
+
| ~~`POST /api/instances/register`~~ | **Dropped** - see below. |
|
|
938
|
+
| ~~`POST /api/instances/heartbeat`~~ | **Deferred.** Opt-in and off by default, per 2026-04-08; still nothing calls it. |
|
|
939
|
+
|
|
940
|
+
### How a fleet child authenticates (revised)
|
|
941
|
+
|
|
942
|
+
The register handshake was designed for an instance the manager did **not** start: HMAC a fingerprint
|
|
943
|
+
with a shared `INSTANCE_SECRET`, receive a licence token back. That is the right shape for a customer's
|
|
944
|
+
own server phoning home. It is the wrong shape for this fleet, where the manager forks every child
|
|
945
|
+
itself and therefore already knows, with certainty, which site is on the other end - a handshake could
|
|
946
|
+
only re-establish something we never lost.
|
|
947
|
+
|
|
948
|
+
So `processManager` mints a token when it forks a child and hands it over in the environment, alongside
|
|
949
|
+
`MANAGER_URL` and `SITE_SLUG`. Three properties a shared secret would not have:
|
|
950
|
+
|
|
951
|
+
- **Nothing on disk.** No secret to distribute to nine sites, drift, or leak through a backup.
|
|
952
|
+
- **It rotates on every restart**, so a token read from a process listing or an old log stops working
|
|
953
|
+
the moment the site is restarted. `stop()` revokes, because nothing should still speak for a site
|
|
954
|
+
that is not running.
|
|
955
|
+
- **A token names exactly one site.** There is no credential anywhere that speaks for the fleet, so a
|
|
956
|
+
compromised child cannot read another site's licences.
|
|
957
|
+
|
|
958
|
+
The fleet env is applied *after* the site's own `.env`, so a site cannot claim a different slug or
|
|
959
|
+
point itself at another manager.
|
|
960
|
+
|
|
961
|
+
`isManaged()` requires the token as well as the URL: a site with one and not the other used to send
|
|
962
|
+
`Bearer undefined` and report the resulting 401 as though the manager had rejected it.
|
|
963
|
+
|
|
964
|
+
**What the catalogue joins.** Artifact facts from `catalogue.json` (rebuilt on release), prices from the
|
|
965
|
+
terms store (changed whenever somebody changes a number), and `entitled` computed per request from the
|
|
966
|
+
live ledger and never stored - so revoking a licence takes effect on the next refresh rather than the
|
|
967
|
+
next catalogue build. A plugin the site cannot have is still listed, disabled: a customer who cannot see
|
|
968
|
+
a plugin cannot buy one.
|
|
969
|
+
|
|
970
|
+
**The entitlement minted on this path is a RENTAL** ending when the licence does. Perpetual belongs to
|
|
971
|
+
the standalone download and is unreachable from here; confusing the two would hand a monthly renter
|
|
972
|
+
something that never stops, or expire something bought outright.
|
|
973
|
+
| `GET …/plugins/:slug/download?licence=…` | **New.** Mint and return a `.dcmsplugin` envelope for a paid licence. |
|
|
974
|
+
| `GET/PUT …/plugin-terms` | **New.** Read and amend commercial terms per plugin. |
|
|
975
|
+
|
|
976
|
+
Plus:
|
|
977
|
+
|
|
978
|
+
- `paid-plugins/` becomes a monorepo checkout rather than the source of truth.
|
|
979
|
+
- The Plugin Store view gains a **Download standalone** action beside the existing per-site grant.
|
|
980
|
+
- A new **Plugin Terms** view (`admin/views/plugin-terms.js` + template, sidebar entry beside
|
|
981
|
+
Plugin Store and Licences) edits `data/plugin-terms.json`.
|
|
982
|
+
- `licenceManager` gains the model distinction: a rental carries `expiresAt`, a perpetual licence
|
|
983
|
+
carries `supportExpiresAt` and a version ceiling, and `runExpiryCheck()` must **not** deactivate
|
|
984
|
+
a perpetual licence when its support window ends.
|
|
985
|
+
|
|
986
|
+
### Upgrade path
|
|
987
|
+
|
|
988
|
+
**Built** (`upgradePlugin()` in `pluginInstaller.js`; `tests/services/plugin-upgrade.test.js`).
|
|
989
|
+
`installPlugin()` used to refuse an existing directory outright, which made this a marketplace you
|
|
990
|
+
could buy from once and never update from.
|
|
991
|
+
|
|
992
|
+
1. Compare manifest versions - **numerically**, so 1.10.0 is newer than 1.2.0 - and refuse a
|
|
993
|
+
downgrade unless forced.
|
|
994
|
+
2. Extract to `.<slug>.incoming` and validate the manifest **there**, via the same
|
|
995
|
+
`validatePluginDir()` that `discoverPlugins()` uses. Validating after the swap would mean
|
|
996
|
+
finding out the new version is broken with the old one already gone.
|
|
997
|
+
3. Carry `plugins/<slug>/data/` and `.entitlement.json` across: both belong to the site rather than
|
|
998
|
+
the version, and a renewal dropped in yesterday must survive today's update.
|
|
999
|
+
4. Rename the old directory to `.<slug>.backup`, swap, and only then delete the backup. On any
|
|
1000
|
+
failure the previous directory goes back. The one state this must never produce is "neither
|
|
1001
|
+
version installed".
|
|
1002
|
+
|
|
1003
|
+
The upload route asks for consent rather than reporting a refusal: a second upload of an installed
|
|
1004
|
+
slug answers **409** carrying both version numbers, which the Marketplace turns into "Update 1.0.0
|
|
1005
|
+
to 1.1.0?". An unsigned file and a downgrade each take their own confirmation. Consent flags are
|
|
1006
|
+
read after the file is drained, so where the browser puts them in the multipart body cannot decide
|
|
1007
|
+
whether they are seen.
|
|
1008
|
+
|
|
1009
|
+
## Sequencing
|
|
1010
|
+
|
|
1011
|
+
Ordered so that the riskiest thing gets fixed first and the easiest last.
|
|
1012
|
+
|
|
1013
|
+
**1. Settle the duplicate slugs.** Decide Surveys' tier (finding #8) and whether `theme-roller`
|
|
1014
|
+
overlaps `theme-switcher` enough to merge. Blocks everything: the catalogue cannot be generated
|
|
1015
|
+
while one slug has two tiers. *Exit test:* every slug across both repos resolves to exactly one
|
|
1016
|
+
tier.
|
|
1017
|
+
|
|
1018
|
+
**2. Consolidate into one repo, and extract `invoice`.** Create `dcms-marketplace`; import the paid
|
|
1019
|
+
set from the manager's `paid-plugins/` (**the source**, not the minified site copies), the
|
|
1020
|
+
manager-only plugins from `domma-cms-manager/plugins/`, and the free set from `domma-cms/plugins/`. Then run [the extraction](#extracting-a-plugin-that-shipped-free) for `invoice`:
|
|
1021
|
+
grandfather the four live sites **first**, then remove it from `domma-cms/plugins/`. Do the same for
|
|
1022
|
+
`surveys` (two licence records, no running installs). Replace `SKIP_PLUGINS` with the tier check, and
|
|
1023
|
+
delete the stray paid copies from the eight existing sites along with the `form-builder` and
|
|
1024
|
+
`site-search` residue. Withdraw `seo-pro` in the order given in
|
|
1025
|
+
[Withdrawing seo-pro](#withdrawing-seo-pro-and-reserving-a-slug) - `proBundle` first, then
|
|
1026
|
+
`rescindPlugin()` per site - and write `reserved.json`. *Exit test:* a fresh clone plus
|
|
1027
|
+
`make catalogue` reproduces every currently deployed paid plugin byte-for-byte; the four invoice
|
|
1028
|
+
sites each hold an active rental licence with no restart having occurred; a newly scaffolded site contains
|
|
1029
|
+
free-tier plugins only; and enabling Pro on a site issues no `seo-pro` licence.
|
|
1030
|
+
The value here is consolidation, not rescue: the paid plugins already have history (finding #11),
|
|
1031
|
+
they are just filed inside the fleet supervisor. **The actual unbacked code is the five site-only
|
|
1032
|
+
plugins**, and `reactive-showcase` at 9,587 LOC should be put somewhere versioned regardless of
|
|
1033
|
+
whether the marketplace is ever built. It is not a marketplace plugin and does not belong in the
|
|
1034
|
+
monorepo - a repo of its own, or the `reactive` site's own repo, is the right home.
|
|
1035
|
+
|
|
1036
|
+
**3. Catalogue generation and key rotation.** `build-catalogue.js`, and the key work in
|
|
1037
|
+
[Signing keys](#signing-keys): generate the Ed25519 key `marketplace-1` offline, add it to
|
|
1038
|
+
`TRUSTED_KEYS`, **delete `manager-2026`**, and switch the verifier to `crypto.verify(null, …)`. *Exit test:* a generated bundle
|
|
1039
|
+
installs on a scratch CMS; flipping one byte in the tarball makes it fail loudly; and a bundle signed
|
|
1040
|
+
with the old key is refused as an unknown keyId.
|
|
1041
|
+
|
|
1042
|
+
**4. Export and import as one file.** The envelope reader and writer, `GET /plugins/:name/export`,
|
|
1043
|
+
`POST /plugins/install-upload`, the `closedSource` export refusal, and the unsigned-import policy.
|
|
1044
|
+
Independent of entitlements and useful on its own: it is also the tool that makes step 2 repeatable
|
|
1045
|
+
rather than a one-off copy, since an orphaned paid plugin can be exported off a live site instead of
|
|
1046
|
+
lifted out of a directory by hand. *Exit test:* tests 10 to 13 below.
|
|
1047
|
+
|
|
1048
|
+
**5. Child-side entitlement verification, and the terms store.** `pluginEntitlement.js`, the load
|
|
1049
|
+
gate, the entitlement route, the admin surface; on the manager side, `plugin-terms.json`, the Plugin
|
|
1050
|
+
Terms view, and the issuing half of `licenceManager` for both models. **Gates selling to anyone
|
|
1051
|
+
outside the fleet** - until it exists, a standalone download is unenforceable. The builder needs no
|
|
1052
|
+
work at any point here: it is already free and must stay untouched. *Exit test:* the table in
|
|
1053
|
+
[Entitlement](#entitlement), each row observed, plus tests 15 to 18 below.
|
|
1054
|
+
|
|
1055
|
+
**6. Wire the existing client to the new endpoints.** **Done.** Two routes rather than four (the
|
|
1056
|
+
register handshake was dropped, heartbeat deferred), and `MANAGER_URL` is injected at fork rather than
|
|
1057
|
+
set per site. *Exit test passed on pj-plastering:* it lists all fourteen catalogue entries over HTTP,
|
|
1058
|
+
shows `invoice` and `docs` as Licensed from the real ledger, refuses `docs` before its licence exists
|
|
1059
|
+
and installs it after, and updates 1.0.0 to 1.1.0 from the Browse tab keeping its data and entitlement.
|
|
1060
|
+
|
|
1061
|
+
One thing the exit test found: **the Browse tab had never mounted at all.** It loaded its template with
|
|
1062
|
+
`H.get`, which ends every call with `response.json()`, so an HTML template threw on the first `<`. The
|
|
1063
|
+
lazy mount caught that and left the panel empty - indistinguishable from the empty catalogue the
|
|
1064
|
+
manager was returning anyway, which is why it survived this long.
|
|
1065
|
+
|
|
1066
|
+
## Testing
|
|
1067
|
+
|
|
1068
|
+
The 2026-04-08 verification list still stands and is not repeated. In addition:
|
|
1069
|
+
|
|
1070
|
+
1. **Free tier offline.** Fresh `npm install domma-cms`, no network: every free plugin loads. No
|
|
1071
|
+
entitlement code path runs for a plugin without `closedSource`.
|
|
1072
|
+
2. **Envelope tampering.** Flip one byte in `plugin.tar.gz` inside the envelope - install fails,
|
|
1073
|
+
nothing is left in `plugins/`.
|
|
1074
|
+
3. **Entitlement tampering.** Extend `expiresAt` by hand - signature fails, plugin does not load,
|
|
1075
|
+
admin reports `unlicensed`.
|
|
1076
|
+
4. **Slug mismatch.** Envelope named for one slug, manifest declaring another - refused.
|
|
1077
|
+
5. **Expiry and grace.** Backdate `expiresAt` into grace: loads with a warning. Past grace: does
|
|
1078
|
+
not load, `config/plugins.json` untouched, files present.
|
|
1079
|
+
6. **Renewal without reinstall.** Drop a fresh entitlement, restart, plugin loads.
|
|
1080
|
+
7. **Supersede interaction.** `email-pro` installed and entitled displaces `mail-reader`; let the
|
|
1081
|
+
entitlement expire and confirm `mail-reader` returns on the next boot rather than both going dark.
|
|
1082
|
+
8. **Upgrade preserves data.** Install v1, write plugin data, upgrade to v2, data intact.
|
|
1083
|
+
9. **Upgrade rollback.** Upgrade with a deliberately broken manifest in the new version - previous
|
|
1084
|
+
version and its data restored.
|
|
1085
|
+
10. **Export and reimport round-trip.** Export `notes` from one CMS, import on a second, restart:
|
|
1086
|
+
same version, collections and forms created by `setupPlugin()`, plugin runs. Exported twice from
|
|
1087
|
+
the same state, the envelope is byte-identical.
|
|
1088
|
+
11. **Export excludes state.** Write plugin data and collection entries, export, import elsewhere:
|
|
1089
|
+
no `data/`, no entries, schema present, plugin lands disabled.
|
|
1090
|
+
12. **Paid export refused.** `GET /plugins/email-pro/export` answers 403, and the Export action is
|
|
1091
|
+
absent from its card rather than present and failing.
|
|
1092
|
+
13. **Unsigned import policy.** With `requireSignedPlugins: false`, an unsigned envelope installs
|
|
1093
|
+
only after confirmation and logs `install:unsigned`. With it `true`, refused. A *tampered*
|
|
1094
|
+
signature is refused under both settings.
|
|
1095
|
+
14. **Rental expiry stops the plugin.** A `model: "rental"` entitlement past `expiresAt` plus grace:
|
|
1096
|
+
plugin does not load, reported `"expired"`, files and `config/plugins.json` untouched.
|
|
1097
|
+
15. **Perpetual support lapse does not.** A `model: "perpetual"` entitlement with
|
|
1098
|
+
`supportExpiresAt` in the past: plugin **loads**, reported `"support-ended"`, indefinitely and
|
|
1099
|
+
with no network. Installing a version above the `versions` ceiling is refused with the reason;
|
|
1100
|
+
installing one within it still works.
|
|
1101
|
+
16. **Renewal by file drop.** Post a fresh entitlement with a later ceiling, restart: the newer
|
|
1102
|
+
version installs. No reinstall of the plugin itself.
|
|
1103
|
+
17. **Terms edits do not reach issued entitlements.** Change `monthly` and `licence` in Plugin
|
|
1104
|
+
Terms; every existing entitlement and ledger record is byte-identical afterwards, and a live
|
|
1105
|
+
rental is not re-rated.
|
|
1106
|
+
18. **The builder is untouched by all of it.** On a CMS with no entitlement file anywhere, scaffold
|
|
1107
|
+
a plugin from the admin and from the CLI, edit it in the code editor, enable it, export it:
|
|
1108
|
+
all succeed, and no entitlement check runs at any point.
|
|
1109
|
+
19. **A new site is seeded with free plugins only.** Scaffold a site: no `calendar`, `docs`,
|
|
1110
|
+
`email-pro`, `invoice`, `messages` or `surveys` on disk. Grant one, and it appears.
|
|
1111
|
+
20. **A reserved slug cannot ship or be granted.** `make catalogue` with a `seo-pro` directory
|
|
1112
|
+
present emits no bundle for it and says why; the Plugin Store offers no grant for it.
|
|
1113
|
+
21. **The retired key is untrusted.** A bundle bearing `publicKeyId: "manager-2026"` is refused as
|
|
1114
|
+
an unknown key, not merely as a bad signature.
|
|
1115
|
+
22. **Ed25519 round-trip.** A bundle signed offline verifies on a scratch CMS; one flipped byte
|
|
1116
|
+
fails; and a verifier still passing `'sha256'` throws `ERR_OSSL_INVALID_DIGEST` rather than
|
|
1117
|
+
accepting anything.
|
|
1118
|
+
23. **Fleet path unregressed.** Grant and rescind a paid plugin from the Plugin Store exactly as
|
|
1119
|
+
today; `getEntitlements()` still reports ground truth.
|
|
1120
|
+
24. **No supervisor.** On a bare `node server/server.js`, install reports that a manual restart is
|
|
1121
|
+
needed rather than exiting into a dead site.
|
|
1122
|
+
|
|
1123
|
+
## Deliberately deferred
|
|
1124
|
+
|
|
1125
|
+
- **Payments.** Stripe, invoicing, dunning. The ledger records entitlement; it does not take money.
|
|
1126
|
+
- **Auto-update of paid plugins.** Manual click for v1, per 2026-04-08.
|
|
1127
|
+
- **Hardware or domain binding.** Purchaser identity only for v1. Fingerprint activation is a v2
|
|
1128
|
+
option and `registerInstance()` already exists for it.
|
|
1129
|
+
- **Public anonymous browsing UI.** The static catalogue is machine-readable first; a marketing
|
|
1130
|
+
storefront is separate work.
|
|
1131
|
+
- **Heartbeat and telemetry.** Stays opt-in and off by default.
|
|
1132
|
+
- **Search, ratings, third-party authors.** A marketplace with one vendor needs none of it, and
|
|
1133
|
+
third-party plugins would change the signing model completely.
|
|
1134
|
+
|
|
1135
|
+
## Open questions
|
|
1136
|
+
|
|
1137
|
+
1. **Currency and tax.** `terms.json` carries one currency per plugin. Does a non-UK customer see
|
|
1138
|
+
GBP, or do terms need per-currency amounts? VAT and reverse charge are unaddressed and belong
|
|
1139
|
+
with payments, but the *displayed* figure needs a decision sooner than payments do.
|
|
1140
|
+
2. **Proration on managed rentals.** Granting a paid plugin mid-month: charge the full month, prorate,
|
|
1141
|
+
or start the term at the grant? `minimumTermMonths` exists in the shape but nothing reads it yet.
|
|
1142
|
+
3. **Third-party authors.** Revenue share, attribution, support obligations, and whether a
|
|
1143
|
+
third-party plugin can be *paid* at all. Signing code we did not write is the part that needs the
|
|
1144
|
+
most thought; accepting free submissions needs almost none of it.
|
|
1145
|
+
4. **Should `requireSignedPlugins` default `true` on a fleet child?** The updater merges
|
|
1146
|
+
`config/server.json`, so the default could differ by how a site was provisioned rather than by a
|
|
1147
|
+
global choice. Leaning yes for fleet children, `false` for a self-hosted install.
|
|
1148
|
+
5. **Does the free tier stay vendored in `domma-cms/plugins/`, or move to the monorepo entirely?**
|
|
1149
|
+
Vendoring keeps `npm install` self-contained, which is the current behaviour and worth keeping,
|
|
1150
|
+
but it does mean the free subset exists in two working trees with a sync step between them.
|