domma-cms 1.6.59 → 1.7.0

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.
Files changed (143) hide show
  1. package/CHANGELOG.md +4344 -0
  2. package/README.md +1 -1
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +3 -3
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +2 -2
  7. package/admin/js/lib/draft-owner.js +1 -0
  8. package/admin/js/lib/effect-defs.js +1 -1
  9. package/admin/js/lib/effects-builder.js +2 -2
  10. package/admin/js/lib/page-editor-reference.js +5 -5
  11. package/admin/js/lib/shortcode-tags.js +1 -1
  12. package/admin/js/templates/docs/usage-shortcodes.html +39 -0
  13. package/admin/js/views/user-editor.js +1 -1
  14. package/docs/DommaFeatures.md +3005 -0
  15. package/docs/MultiTenant.md +176 -0
  16. package/docs/api-reference.md +1667 -0
  17. package/docs/cache.md +96 -0
  18. package/docs/configuration.md +1811 -0
  19. package/docs/editions-and-licences.md +190 -0
  20. package/docs/getting-started.md +201 -0
  21. package/docs/guides/form-triggers.md +260 -0
  22. package/docs/guides/mongodb-storage-adapter.md +282 -0
  23. package/docs/guides/multi-page-forms.md +121 -0
  24. package/docs/images/scribe-composer.png +0 -0
  25. package/docs/markdown-shortcodes.md +1738 -0
  26. package/docs/pentest-report-2026-04-03.md +181 -0
  27. package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
  28. package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
  29. package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
  30. package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
  31. package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
  32. package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
  33. package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
  34. package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
  35. package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
  36. package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
  37. package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
  38. package/docs/plans/2026-03-05-user-types-design.md +139 -0
  39. package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
  40. package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
  41. package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
  42. package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
  43. package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
  44. package/docs/plans/2026-06-10-core-project-design.md +240 -0
  45. package/docs/plugin-development.md +827 -0
  46. package/docs/recruitment-recipe.md +501 -0
  47. package/docs/scaffolding.md +312 -0
  48. package/docs/screenshots/calculator-floating.png +0 -0
  49. package/docs/screenshots/calculator-standard.png +0 -0
  50. package/docs/screenshots/email-settings.png +0 -0
  51. package/docs/screenshots/messages-count.png +0 -0
  52. package/docs/structure/user-types.md +107 -0
  53. package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
  54. package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
  55. package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
  56. package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
  57. package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
  58. package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
  59. package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
  60. package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
  61. package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
  62. package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
  63. package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
  64. package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
  65. package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
  66. package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
  67. package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
  68. package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
  69. package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
  70. package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
  71. package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
  72. package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
  73. package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
  74. package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
  75. package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
  76. package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
  77. package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
  78. package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
  79. package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
  80. package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
  81. package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
  82. package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
  83. package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
  84. package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
  85. package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
  86. package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
  87. package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
  88. package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
  89. package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
  90. package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
  91. package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
  92. package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
  93. package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
  94. package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
  95. package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
  96. package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
  97. package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
  98. package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
  99. package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
  100. package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
  101. package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
  102. package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
  103. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
  104. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
  105. package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
  106. package/docs/theming.md +320 -0
  107. package/package.json +2 -2
  108. package/plugins/_lib/admin/mail/keyboard.js +49 -0
  109. package/plugins/_lib/admin/mail/reader-view.js +4 -2
  110. package/public/js/celebrations/core/canvas.js +1 -1
  111. package/public/js/celebrations/index.js +1 -1
  112. package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
  113. package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
  114. package/public/js/celebrations/message-attrs.mjs +1 -0
  115. package/public/js/celebrations/themes/anniversary.js +1 -0
  116. package/public/js/celebrations/themes/autumn.js +1 -0
  117. package/public/js/celebrations/themes/birthday.js +1 -0
  118. package/public/js/celebrations/themes/butterfly-flight.js +1 -0
  119. package/public/js/celebrations/themes/calendar.js +1 -0
  120. package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
  121. package/public/js/celebrations/themes/chinese-new-year.js +1 -0
  122. package/public/js/celebrations/themes/christmas.js +1 -1
  123. package/public/js/celebrations/themes/easter.js +1 -0
  124. package/public/js/celebrations/themes/garden-drawing.js +1 -0
  125. package/public/js/celebrations/themes/garden.js +1 -0
  126. package/public/js/celebrations/themes/graduation.js +1 -0
  127. package/public/js/celebrations/themes/greeting.js +1 -0
  128. package/public/js/celebrations/themes/make-it-rain.js +1 -0
  129. package/public/js/celebrations/themes/money-rain.js +1 -0
  130. package/public/js/celebrations/themes/new-year.js +1 -0
  131. package/public/js/celebrations/themes/occasion-drawing.js +1 -0
  132. package/public/js/celebrations/themes/occasions.js +1 -0
  133. package/public/js/celebrations/themes/rain-control.js +1 -0
  134. package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
  135. package/public/js/celebrations/themes/sparkler-font.js +1 -0
  136. package/public/js/celebrations/themes/sparkler-writing.js +1 -0
  137. package/public/js/celebrations/themes/summer-garden.js +1 -0
  138. package/public/js/celebrations/themes/winter.js +1 -0
  139. package/public/js/celebrations/trait-data.mjs +1 -1
  140. package/public/js/effects.js +1 -1
  141. package/scripts/build.js +4 -0
  142. package/server/services/markdown.js +5 -2
  143. 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.