domma-cms 0.55.1 → 0.69.3

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 (305) hide show
  1. package/CLAUDE.md +194 -3
  2. package/README.md +18 -14
  3. package/admin/css/admin.css +1 -1
  4. package/admin/css/contacts.css +1 -0
  5. package/admin/dist/domma/domma-tools.css +3 -3
  6. package/admin/dist/domma/domma-tools.min.js +3 -3
  7. package/admin/index.html +1 -0
  8. package/admin/js/api.js +1 -1
  9. package/admin/js/app.js +4 -4
  10. package/admin/js/lib/analytics-shape.js +1 -0
  11. package/admin/js/lib/contacts-arrange.js +1 -0
  12. package/admin/js/lib/help-popover.js +1 -0
  13. package/admin/js/lib/notes-arrange.js +1 -0
  14. package/admin/js/lib/page-picker.js +1 -0
  15. package/admin/js/lib/plugin-accent.js +1 -0
  16. package/admin/js/lib/plugin-chrome.js +1 -0
  17. package/admin/js/lib/shortcode-context-menu.js +2 -2
  18. package/admin/js/lib/sidebar-grouping.js +1 -1
  19. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  20. package/admin/js/lib/sidebar-renderer.js +4 -4
  21. package/admin/js/lib/slideover-resizable.js +1 -0
  22. package/admin/js/lib/todo-arrange.js +1 -0
  23. package/admin/js/lib/tool-kit.js +1 -0
  24. package/admin/js/templates/analytics.html +138 -0
  25. package/admin/js/templates/contacts.html +156 -0
  26. package/admin/js/templates/context-menu-editor.html +212 -0
  27. package/admin/js/templates/context-menus.html +16 -0
  28. package/admin/js/templates/notes.html +137 -0
  29. package/admin/js/templates/plugin-code.html +1 -1
  30. package/admin/js/templates/plugin-marketplace.html +17 -4
  31. package/admin/js/templates/plugins.html +13 -7
  32. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  33. package/admin/js/templates/settings.html +16 -97
  34. package/admin/js/templates/theme.html +211 -0
  35. package/admin/js/templates/todo.html +113 -0
  36. package/admin/js/views/analytics.js +62 -0
  37. package/admin/js/views/contacts.js +174 -0
  38. package/admin/js/views/context-menu-editor.js +55 -0
  39. package/admin/js/views/context-menus.js +5 -0
  40. package/admin/js/views/form-editor.js +7 -7
  41. package/admin/js/views/index.js +1 -1
  42. package/admin/js/views/notes.js +104 -0
  43. package/admin/js/views/plugin-marketplace.js +1 -1
  44. package/admin/js/views/plugins.js +28 -24
  45. package/admin/js/views/search.js +1 -0
  46. package/admin/js/views/settings.js +3 -3
  47. package/admin/js/views/theme.js +1 -0
  48. package/admin/js/views/todo.js +96 -0
  49. package/bin/cli.js +6 -10
  50. package/bin/lib/plugin-version.js +28 -0
  51. package/bin/update.js +27 -2
  52. package/config/menus/admin-sidebar.json +129 -23
  53. package/config/plugins.json +5 -5
  54. package/config/search.json +13 -0
  55. package/config/server.json +3 -1
  56. package/package.json +14 -10
  57. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  58. package/plugins/_lib/admin/mail/contacts.js +301 -0
  59. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  60. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  61. package/plugins/_lib/admin/mail/identity.js +480 -0
  62. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  63. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  64. package/plugins/_lib/admin/mail/mail.css +1 -0
  65. package/plugins/_lib/admin/mail/mail.html +71 -0
  66. package/plugins/_lib/admin/mail/panes.js +253 -0
  67. package/plugins/_lib/admin/mail/reader-view.js +4473 -0
  68. package/plugins/_lib/admin/mail/resizable.js +26 -0
  69. package/plugins/_lib/admin/mail/rules.js +343 -0
  70. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  71. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  72. package/plugins/_lib/admin/mail/templates.js +238 -0
  73. package/plugins/_lib/admin/mail/threads.js +200 -0
  74. package/plugins/_lib/admin/mail/vacation.js +269 -0
  75. package/plugins/_lib/admin/ui/help.css +1 -0
  76. package/plugins/_lib/admin/ui/help.js +174 -0
  77. package/plugins/_lib/admin/ui/resizable.js +151 -0
  78. package/plugins/_lib/dataStore.js +117 -0
  79. package/plugins/_lib/mail/accounts.js +919 -0
  80. package/plugins/_lib/mail/bodyTokens.js +101 -0
  81. package/plugins/_lib/mail/compose.js +256 -0
  82. package/plugins/_lib/mail/defaults.js +57 -0
  83. package/plugins/_lib/mail/diagnostics.js +154 -0
  84. package/plugins/_lib/mail/envelope.js +161 -0
  85. package/plugins/_lib/mail/folders.js +192 -0
  86. package/plugins/_lib/mail/handoff.js +274 -0
  87. package/plugins/_lib/mail/imapPool.js +293 -0
  88. package/plugins/_lib/mail/mbox.js +74 -0
  89. package/plugins/_lib/mail/pollSchedule.js +79 -0
  90. package/plugins/_lib/mail/poller.js +135 -0
  91. package/plugins/_lib/mail/priority.js +138 -0
  92. package/plugins/_lib/mail/readRoutes.js +680 -0
  93. package/plugins/_lib/mail/render.js +291 -0
  94. package/plugins/_lib/mail/ruleRunner.js +152 -0
  95. package/plugins/_lib/mail/scheduler.js +254 -0
  96. package/plugins/_lib/mail/secretbox.js +229 -0
  97. package/plugins/_lib/mail/send.js +396 -0
  98. package/plugins/_lib/mail/store.js +1022 -0
  99. package/plugins/_lib/mail/sync.js +292 -0
  100. package/plugins/_lib/mail/syncPlan.js +126 -0
  101. package/plugins/_lib/mail/syncSelection.js +82 -0
  102. package/plugins/_lib/mail/unsubscribe.js +183 -0
  103. package/plugins/_lib/mail/vacationRunner.js +114 -0
  104. package/plugins/_lib/mail/write.js +473 -0
  105. package/plugins/_lib/schemaSync.js +83 -0
  106. package/plugins/_template/admin/css/index.css +0 -0
  107. package/plugins/_template/admin/templates/index.html +4 -4
  108. package/plugins/_template/admin/views/index.js +7 -0
  109. package/plugins/blog/CLAUDE.md +237 -0
  110. package/plugins/blog/admin/css/index.css +178 -0
  111. package/plugins/blog/admin/templates/blog.html +35 -41
  112. package/plugins/blog/admin/templates/categories.html +19 -6
  113. package/plugins/blog/admin/templates/comments.html +26 -9
  114. package/plugins/blog/admin/templates/post-editor.html +63 -40
  115. package/plugins/blog/admin/templates/settings.html +3 -9
  116. package/plugins/blog/admin/views/blog.js +288 -159
  117. package/plugins/blog/admin/views/categories.js +212 -206
  118. package/plugins/blog/admin/views/comments.js +207 -158
  119. package/plugins/blog/admin/views/kit.js +372 -0
  120. package/plugins/blog/admin/views/post-editor.js +306 -39
  121. package/plugins/blog/admin/views/settings-cog.js +56 -0
  122. package/plugins/blog/admin/views/settings.js +16 -84
  123. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  124. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  125. package/plugins/blog/blocks/blog-card-row.css +22 -0
  126. package/plugins/blog/blocks/blog-card-row.html +11 -0
  127. package/plugins/blog/blocks/blog-card.css +35 -0
  128. package/plugins/blog/blocks/blog-card.html +16 -0
  129. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  130. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  131. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  132. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  133. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  134. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  135. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  136. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  137. package/plugins/blog/collections/categories/schema.json +7 -6
  138. package/plugins/blog/collections/comments/schema.json +11 -10
  139. package/plugins/blog/collections/posts/schema.json +16 -13
  140. package/plugins/blog/config.js +6 -0
  141. package/plugins/blog/lib/layouts.js +304 -0
  142. package/plugins/blog/lib/page.js +158 -0
  143. package/plugins/blog/lib/render.js +119 -0
  144. package/plugins/blog/lib/samples.js +234 -0
  145. package/plugins/blog/plugin.js +259 -26
  146. package/plugins/blog/plugin.json +21 -73
  147. package/plugins/blog/plugin.public.js +217 -0
  148. package/plugins/blog/public/blog.css +177 -0
  149. package/plugins/blog/public/blog.js +586 -0
  150. package/plugins/blog/public/samples/aurora.svg +7 -0
  151. package/plugins/blog/public/samples/dusk.svg +7 -0
  152. package/plugins/blog/public/samples/ember.svg +7 -0
  153. package/plugins/blog/public/samples/harbour.svg +7 -0
  154. package/plugins/blog/public/samples/meadow.svg +7 -0
  155. package/plugins/blog/public/samples/rosewood.svg +7 -0
  156. package/plugins/blog/templates/index.html +9 -8
  157. package/plugins/blog/templates/post.html +4 -17
  158. package/plugins/blog/tests/layouts.test.js +97 -0
  159. package/plugins/blog/tests/public.test.js +71 -0
  160. package/plugins/free-tier.lock.json +74 -0
  161. package/plugins/mail-reader/CLAUDE.md +78 -0
  162. package/plugins/mail-reader/admin/views/mail.js +19 -0
  163. package/plugins/mail-reader/config.js +7 -0
  164. package/plugins/mail-reader/plugin.js +48 -0
  165. package/plugins/mail-reader/plugin.json +34 -0
  166. package/public/css/forms.css +1 -1
  167. package/public/css/search.css +1 -0
  168. package/public/css/site.css +1 -1
  169. package/public/css/theme-switcher.css +1 -0
  170. package/public/js/analytics.js +1 -0
  171. package/public/js/collection-context.js +2 -2
  172. package/public/js/context-menus.js +1 -0
  173. package/public/js/form-logic-engine.js +1 -1
  174. package/public/js/forms.js +2 -2
  175. package/public/js/search.js +1 -0
  176. package/public/js/site.js +1 -1
  177. package/public/js/theme-switcher.js +1 -0
  178. package/scripts/build.js +77 -4
  179. package/scripts/copy-domma.js +48 -0
  180. package/scripts/run-tests.mjs +92 -0
  181. package/scripts/seed.js +1996 -0
  182. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/contacts.js +506 -0
  185. package/server/routes/api/context-menus.js +104 -0
  186. package/server/routes/api/dashboard.js +8 -2
  187. package/server/routes/api/forms.js +42 -3
  188. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  189. package/server/routes/api/notifications.js +69 -19
  190. package/server/routes/api/plugin-marketplace.js +78 -23
  191. package/server/routes/api/plugins.js +198 -9
  192. package/server/routes/api/search.js +43 -0
  193. package/server/routes/api/theme.js +69 -0
  194. package/server/routes/api/todo.js +178 -0
  195. package/server/routes/public.js +42 -7
  196. package/server/server.js +135 -1
  197. package/server/services/adapters/FileAdapter.js +6 -1
  198. package/server/services/collections.js +13 -3
  199. package/server/services/content.js +26 -0
  200. package/server/services/contextMenus.js +477 -0
  201. package/server/services/managerClient.js +72 -136
  202. package/server/services/markdown.js +124 -10
  203. package/server/services/permissionRegistry.js +74 -0
  204. package/server/services/pluginEntitlement.js +171 -0
  205. package/server/services/pluginEnvelope.js +242 -0
  206. package/server/services/pluginFiles.js +52 -11
  207. package/server/services/pluginInstaller.js +246 -26
  208. package/server/services/pluginScaffold.js +5 -0
  209. package/server/services/plugins.js +340 -7
  210. package/server/services/presetCollections.js +344 -0
  211. package/server/services/promoted-plugins-migration.js +254 -0
  212. package/server/services/publicCors.js +46 -0
  213. package/server/services/renderer.js +224 -22
  214. package/server/services/roles.js +1 -1
  215. package/server/services/search-migration.js +82 -0
  216. package/server/services/search.js +413 -0
  217. package/server/services/sidebar-migration.js +1 -0
  218. package/server/services/sidebarBadges.js +37 -0
  219. package/server/services/siteGitignore.js +307 -0
  220. package/server/services/themeSettings.js +580 -0
  221. package/server/services/users.js +8 -0
  222. package/server/templates/page.html +4 -2
  223. package/plugins/analytics/admin/templates/analytics.html +0 -61
  224. package/plugins/analytics/admin/views/analytics.js +0 -176
  225. package/plugins/analytics/config.js +0 -14
  226. package/plugins/analytics/plugin.json +0 -57
  227. package/plugins/analytics/public/inject-body.html +0 -60
  228. package/plugins/analytics/public/inject-head.html +0 -1
  229. package/plugins/blog/templates/author.html +0 -9
  230. package/plugins/blog/templates/category.html +0 -9
  231. package/plugins/blog/templates/tag.html +0 -9
  232. package/plugins/contacts/admin/templates/contacts.html +0 -126
  233. package/plugins/contacts/admin/views/contacts.js +0 -710
  234. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  235. package/plugins/contacts/collections/user-contacts/schema.json +0 -71
  236. package/plugins/contacts/config.js +0 -6
  237. package/plugins/contacts/data/contacts.json +0 -20
  238. package/plugins/contacts/plugin.js +0 -291
  239. package/plugins/contacts/plugin.json +0 -33
  240. package/plugins/demo-viewer/config.js +0 -4
  241. package/plugins/demo-viewer/plugin.js +0 -36
  242. package/plugins/demo-viewer/plugin.json +0 -9
  243. package/plugins/invoice/admin/templates/editor.html +0 -129
  244. package/plugins/invoice/admin/templates/index.html +0 -43
  245. package/plugins/invoice/admin/templates/issuers.html +0 -5
  246. package/plugins/invoice/admin/templates/receivers.html +0 -5
  247. package/plugins/invoice/admin/views/editor.js +0 -267
  248. package/plugins/invoice/admin/views/index.js +0 -155
  249. package/plugins/invoice/admin/views/issuers.js +0 -23
  250. package/plugins/invoice/admin/views/party-view.js +0 -148
  251. package/plugins/invoice/admin/views/receivers.js +0 -22
  252. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
  253. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
  254. package/plugins/invoice/collections/invoices/schema.json +0 -27
  255. package/plugins/invoice/config.js +0 -16
  256. package/plugins/invoice/plugin.js +0 -283
  257. package/plugins/invoice/plugin.json +0 -85
  258. package/plugins/invoice/templates/invoice-print.html +0 -213
  259. package/plugins/notes/admin/templates/notes.html +0 -83
  260. package/plugins/notes/admin/views/notes.js +0 -281
  261. package/plugins/notes/collections/user-notes/schema.json +0 -54
  262. package/plugins/notes/config.js +0 -6
  263. package/plugins/notes/data/notes.json +0 -1
  264. package/plugins/notes/plugin.json +0 -33
  265. package/plugins/site-search/admin/views/site-search.js +0 -116
  266. package/plugins/site-search/config.js +0 -15
  267. package/plugins/site-search/plugin.js +0 -188
  268. package/plugins/site-search/plugin.json +0 -40
  269. package/plugins/site-search/public/inject-body.html +0 -17
  270. package/plugins/site-search/public/inject-head.html +0 -1
  271. package/plugins/site-search/public/search.css +0 -1
  272. package/plugins/site-search/public/search.js +0 -1
  273. package/plugins/surveys/admin/templates/audience.html +0 -47
  274. package/plugins/surveys/admin/templates/results.html +0 -58
  275. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  276. package/plugins/surveys/admin/templates/surveys.html +0 -25
  277. package/plugins/surveys/admin/views/audience.js +0 -301
  278. package/plugins/surveys/admin/views/results.js +0 -172
  279. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  280. package/plugins/surveys/admin/views/surveys.js +0 -161
  281. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  282. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  283. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  284. package/plugins/surveys/collections/surveys/schema.json +0 -23
  285. package/plugins/surveys/config.js +0 -8
  286. package/plugins/surveys/plugin.js +0 -174
  287. package/plugins/surveys/plugin.json +0 -65
  288. package/plugins/surveys/public/dist-shared.mjs +0 -1
  289. package/plugins/surveys/public/survey.css +0 -1
  290. package/plugins/surveys/public/survey.mjs +0 -1
  291. package/plugins/surveys/templates/survey-page.html +0 -63
  292. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  293. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  294. package/plugins/theme-switcher/config.js +0 -10
  295. package/plugins/theme-switcher/plugin.js +0 -26
  296. package/plugins/theme-switcher/plugin.json +0 -74
  297. package/plugins/theme-switcher/public/inject-body.html +0 -153
  298. package/plugins/theme-switcher/public/inject-head.html +0 -260
  299. package/plugins/todo/admin/templates/todo.html +0 -158
  300. package/plugins/todo/admin/views/todo.js +0 -343
  301. package/plugins/todo/collections/todos/schema.json +0 -60
  302. package/plugins/todo/config.js +0 -7
  303. package/plugins/todo/data/todos.json +0 -1
  304. package/plugins/todo/plugin.js +0 -102
  305. package/plugins/todo/plugin.json +0 -33
@@ -24,12 +24,47 @@ import {
24
24
  registerTransform
25
25
  } from './hooks.js';
26
26
  import {registerPluginResource, unregisterPluginResourcesByPlugin} from './permissionRegistry.js';
27
+ import {classifyEntitlement, mayLoad} from './pluginEntitlement.js';
27
28
  import {createCollection, getCollection} from './collections.js';
28
29
 
29
30
  const PLUGINS_DIR = path.resolve('plugins');
30
31
 
31
32
  const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'version', 'description', 'author', 'date', 'icon'];
32
33
 
34
+ /**
35
+ * Validate a candidate plugin directory the way discoverPlugins() would.
36
+ *
37
+ * Exported so an UPGRADE can check what it extracted before swapping it over a
38
+ * working installation. Validating after the swap would mean discovering the
39
+ * new version is broken with the old one already gone.
40
+ *
41
+ * @param {string} dir - absolute directory
42
+ * @param {string} slug
43
+ * @returns {Promise<{ok: boolean, reason?: string, manifest?: object}>}
44
+ */
45
+ export async function validatePluginDir(dir, slug) {
46
+ let manifest;
47
+ try {
48
+ manifest = JSON.parse(await fs.readFile(path.join(dir, 'plugin.json'), 'utf8'));
49
+ } catch {
50
+ return {ok: false, reason: 'no readable plugin.json'};
51
+ }
52
+ for (const field of REQUIRED_MANIFEST_FIELDS) {
53
+ if (!manifest[field]) return {ok: false, reason: `missing required field "${field}"`};
54
+ }
55
+ if (manifest.name !== slug) {
56
+ return {ok: false, reason: `manifest name is "${manifest.name}", not "${slug}"`};
57
+ }
58
+ for (const file of ['plugin.js', 'config.js']) {
59
+ try {
60
+ await fs.access(path.join(dir, file));
61
+ } catch {
62
+ return {ok: false, reason: `missing required file ${file}`};
63
+ }
64
+ }
65
+ return {ok: true, manifest};
66
+ }
67
+
33
68
  const failedLoads = [];
34
69
 
35
70
  /**
@@ -60,11 +95,45 @@ export function getPluginLoadFailures() {
60
95
  */
61
96
  const _loadedPlugins = {};
62
97
 
98
+ /**
99
+ * Entitlement state per licensed plugin, as of the last registerPlugins().
100
+ * Reported by GET /api/plugins so the screen can explain an absence rather than
101
+ * simply not showing a plugin the operator knows they installed.
102
+ *
103
+ * @type {Object.<string, string>}
104
+ */
105
+ const _entitlementStates = {};
106
+
107
+ /** Entitlement states that still let a plugin load. Mirrors pluginEntitlement. */
108
+ const ENTITLEMENT_LOADABLE = new Set(['valid', 'grace', 'support-ended', 'not-required']);
109
+
110
+ /** @returns {Object.<string, string>} */
111
+ export function getEntitlementStates() {
112
+ return {..._entitlementStates};
113
+ }
114
+
63
115
  /**
64
116
  * Core plugins - always loaded regardless of plugins.json enabled state.
65
- * These are considered first-class CMS features, not optional add-ons.
117
+ * These are considered first-class CMS features, not optional add-ons: they
118
+ * ship with the CMS, cannot be switched off, and their sidebar entries file
119
+ * under Tools rather than Plugins.
120
+ *
121
+ * Being in here is the whole mechanism. `enabled` in plugins.json is ignored
122
+ * for these, so a site that had one switched off still gets it on the next
123
+ * start - which is the point of promoting one.
124
+ *
125
+ * It is also a waiting room rather than a destination, and as of 0.67.0 it is
126
+ * EMPTY. site-search sat here until search moved into the CMS proper, and
127
+ * analytics until it did the same - a feature nobody may switch off has no
128
+ * business being discovered, listed and toggled as though it were an add-on.
129
+ *
130
+ * Kept rather than deleted because the mechanism is the right one for the next
131
+ * promotion: a plugin lands here while its replacement is built, then leaves.
66
132
  */
67
- const CORE_PLUGINS = new Set(['analytics']);
133
+ const CORE_PLUGINS = new Set([]);
134
+
135
+ /** Is this plugin a built-in feature rather than an optional add-on? */
136
+ export function isCorePlugin(name) { return CORE_PLUGINS.has(name); }
68
137
 
69
138
  /**
70
139
  * Scan the plugins/ directory and return all valid manifests.
@@ -187,6 +256,94 @@ export function savePluginState(name, state) {
187
256
  saveConfig('plugins', states);
188
257
  }
189
258
 
259
+ /**
260
+ * Work out which plugins are pushed aside by others that are loading.
261
+ *
262
+ * A plugin declares `"supersedes": ["other-plugin"]` in its manifest when it
263
+ * replaces another outright - the Pro edition of something that also ships
264
+ * free, for instance. The superseded plugin then registers nothing at all: no
265
+ * routes, no sidebar item, no collections, so the admin shows one of the pair
266
+ * rather than two that do the same job.
267
+ *
268
+ * The claim lives on the REPLACEMENT, never on the plugin being replaced, so
269
+ * a bundled plugin needs no knowledge of the paid one that may one day
270
+ * displace it.
271
+ *
272
+ * ## This is computed every boot and never written to config/plugins.json
273
+ *
274
+ * Persisting it would be a trap. Licences are withdrawn by disabling a plugin
275
+ * and leaving its files in place, so if superseding had written the free
276
+ * plugin off as disabled, a customer whose licence lapsed would lose the
277
+ * replacement AND the thing it replaced, ending up worse off than someone who
278
+ * never paid. Deriving it at load time means the superseded plugin simply
279
+ * comes back on the next restart.
280
+ *
281
+ * @param {object[]} manifests - every discovered manifest
282
+ * @param {Set<string>} activeNames - names that would otherwise load
283
+ * @param {{isCore?: (name: string) => boolean, warn?: (message: string) => void}} [options]
284
+ * @returns {Map<string, string>} superseded plugin name → the plugin displacing it
285
+ */
286
+ export function resolveSupersessions(manifests, activeNames, options = {}) {
287
+ const {isCore = () => false, warn = () => {}} = options;
288
+ const byName = new Map(manifests.map(m => [m.name, m]));
289
+
290
+ // Collect the claims worth honouring, dropping the nonsensical ones loudly
291
+ // rather than silently - a manifest that claims the wrong thing is a
292
+ // packaging mistake, and a silent no-op makes it very hard to find.
293
+ const claims = new Map();
294
+ for (const manifest of manifests) {
295
+ if (!activeNames.has(manifest.name)) continue;
296
+ const targets = Array.isArray(manifest.supersedes) ? manifest.supersedes : [];
297
+ const kept = [];
298
+
299
+ for (const target of targets) {
300
+ if (target === manifest.name) {
301
+ warn(`"${manifest.name}" lists itself in supersedes - ignored.`);
302
+ continue;
303
+ }
304
+ if (!byName.has(target) || !activeNames.has(target)) continue;
305
+ if (isCore(target)) {
306
+ warn(`"${manifest.name}" cannot supersede core plugin "${target}" - ignored.`);
307
+ continue;
308
+ }
309
+ const rival = byName.get(target);
310
+ if (Array.isArray(rival.supersedes) && rival.supersedes.includes(manifest.name)) {
311
+ warn(`"${manifest.name}" and "${target}" each claim to supersede the other - neither is applied.`);
312
+ continue;
313
+ }
314
+ kept.push(target);
315
+ }
316
+
317
+ if (kept.length) claims.set(manifest.name, kept);
318
+ }
319
+
320
+ if (!claims.size) return new Map();
321
+
322
+ // A superseded plugin's own claims must stop counting, or A>B>C would
323
+ // leave C displaced by a B that is not itself loading. Recompute from
324
+ // scratch each pass until the answer stops changing.
325
+ let superseded = new Map();
326
+ for (let pass = 0; pass <= manifests.length; pass++) {
327
+ const next = new Map();
328
+ for (const [claimant, targets] of claims) {
329
+ if (superseded.has(claimant)) continue;
330
+ for (const target of targets) {
331
+ if (!next.has(target)) next.set(target, claimant);
332
+ }
333
+ }
334
+
335
+ const settled = next.size === superseded.size
336
+ && [...next].every(([name, by]) => superseded.get(name) === by);
337
+ superseded = next;
338
+ if (settled) return superseded;
339
+ }
340
+
341
+ // Only reachable via a supersede cycle longer than a mutual pair. Refusing
342
+ // to apply any of it keeps the outcome deterministic and visible.
343
+ warn('supersedes declarations form a cycle - none are applied.');
344
+ return new Map();
345
+ }
346
+
190
347
  /**
191
348
  * Register server-side Fastify plugins for all enabled plugins.
192
349
  * Always loads plugin.js as the entry point.
@@ -198,11 +355,57 @@ export async function registerPlugins(fastify) {
198
355
  const manifests = await discoverPlugins();
199
356
  const states = getPluginStates();
200
357
 
358
+ // Entitlement is settled BEFORE supersession, not after, and the ordering is
359
+ // load-bearing. `email-pro` supersedes `mail-reader`; if an unentitled
360
+ // email-pro still counted as active it would displace mail-reader and then
361
+ // fail to load itself, leaving a customer whose licence lapsed with NEITHER -
362
+ // worse off than someone who never paid at all.
363
+ for (const manifest of manifests) {
364
+ if (manifest.closedSource !== true) continue;
365
+ const verdict = mayLoad(manifest);
366
+ _entitlementStates[manifest.name] = verdict.state;
367
+ if (!verdict.allowed) {
368
+ fastify.log.warn(`[plugins] "${manifest.name}" not loaded: ${verdict.state} (${verdict.reason})`);
369
+ } else if (verdict.state !== 'valid') {
370
+ fastify.log.info(`[plugins] "${manifest.name}": ${verdict.state} (${verdict.reason})`);
371
+ }
372
+ }
373
+ const entitled = (name) => _entitlementStates[name] === undefined
374
+ || ENTITLEMENT_LOADABLE.has(_entitlementStates[name]);
375
+
376
+ // Which plugins would load on their own account, before anything displaces
377
+ // one another.
378
+ const activeNames = new Set(
379
+ manifests
380
+ .filter(m => (states[m.name]?.enabled || CORE_PLUGINS.has(m.name)) && entitled(m.name))
381
+ .map(m => m.name)
382
+ );
383
+ const superseded = resolveSupersessions(manifests, activeNames, {
384
+ isCore: name => CORE_PLUGINS.has(name),
385
+ warn: message => fastify.log.warn(`[plugins] ${message}`)
386
+ });
387
+
201
388
  const loaded = [];
202
389
  for (const manifest of manifests) {
203
390
  const state = states[manifest.name] || {};
204
391
  if (!state.enabled && !CORE_PLUGINS.has(manifest.name)) continue;
205
392
 
393
+ const displacedBy = superseded.get(manifest.name);
394
+ if (displacedBy) {
395
+ // Recorded rather than merely skipped: the admin Plugins screen
396
+ // needs to say WHY this one is inactive, or the first thing anyone
397
+ // does is toggle it back on and watch nothing happen.
398
+ _loadedPlugins[manifest.name] = {enabled: false, publicEntry: null, supersededBy: displacedBy};
399
+ fastify.log.info(`[plugins] "${manifest.name}" superseded by "${displacedBy}" - not loaded.`);
400
+ continue;
401
+ }
402
+
403
+ // Already classified above, before supersession was resolved. A refusal
404
+ // is NOT written into plugins.json: a lapse recorded as stored state
405
+ // would mean a renewal could not simply restore it, the same reasoning
406
+ // resolveSupersessions() uses for a superseded plugin.
407
+ if (!entitled(manifest.name)) continue;
408
+
206
409
  const entryPath = path.join(PLUGINS_DIR, manifest.name, 'plugin.js');
207
410
  try {
208
411
  const { default: plugin } = await import(entryPath);
@@ -613,6 +816,98 @@ export async function runLifecycleHook(name, hook, fastify) {
613
816
  *
614
817
  * @returns {Promise<{ sidebar: object[], routes: object[], views: object, css: object[] }>}
615
818
  */
819
+ /**
820
+ * The newest mtime anywhere under a directory of browser code.
821
+ *
822
+ * Browser code that is imported rather than routed has no version of its own
823
+ * - it is fetched under whatever token the importing entry carried. Folding
824
+ * the newest mtime of the whole tree into that token is what makes a change
825
+ * to an imported file actually reach a browser. It applies to the shared
826
+ * plugins/_lib/admin as much as to a plugin's own admin/lib, and missing
827
+ * either presents the same way: a fix that is on disk and not in the tab.
828
+ *
829
+ * @param {string} root absolute path; a missing directory is not an error
830
+ * @returns {Promise<number>} epoch milliseconds, or 0 if there is nothing there
831
+ */
832
+ async function newestAdminMtime(root) {
833
+ let newest = 0;
834
+
835
+ /**
836
+ * @param {string} dir
837
+ * @returns {Promise<void>}
838
+ */
839
+ async function walk(dir) {
840
+ let entries;
841
+ try {
842
+ entries = await fs.readdir(dir, {withFileTypes: true});
843
+ } catch {
844
+ return;
845
+ }
846
+ for (const entry of entries) {
847
+ const full = path.join(dir, entry.name);
848
+ if (entry.isDirectory()) {
849
+ await walk(full);
850
+ } else {
851
+ try {
852
+ newest = Math.max(newest, Math.floor((await fs.stat(full)).mtimeMs));
853
+ } catch { /* unreadable - ignore */ }
854
+ }
855
+ }
856
+ }
857
+
858
+ await walk(root);
859
+ return newest;
860
+ }
861
+
862
+ /**
863
+ * The licence line shown on a plugin's admin banner.
864
+ *
865
+ * A manifest may state it outright; otherwise it is derived, because every
866
+ * plugin needs a truthful answer here and almost none of them will ever carry
867
+ * the field. Closed source means it was bought, and the paid plugins are the
868
+ * ones already marked that way for the source-code guard - so the flag that
869
+ * protects the source also names the licence, rather than a second list
870
+ * someone has to remember to add to.
871
+ *
872
+ * @param {object} manifest
873
+ * @returns {string}
874
+ */
875
+ export function pluginLicence(manifest) {
876
+ const stated = manifest.licence || manifest.license;
877
+ if (typeof stated === 'string' && stated.trim()) return stated.trim();
878
+ if (CORE_PLUGINS.has(manifest.name)) return 'Built in';
879
+ if (manifest.closedSource) return 'Commercial licence';
880
+ return 'Included with Domma CMS';
881
+ }
882
+
883
+ /**
884
+ * The expiry shown beside the licence on a plugin's admin banner, or null.
885
+ *
886
+ * Only a licensed plugin has one, and what "expires" means depends on how it
887
+ * was bought (see pluginEntitlement.js): a rental STOPS at its end date, a
888
+ * perpetual licence never does and only its support lapses. The banner says
889
+ * which, because "Expires" on something bought outright would frighten a
890
+ * customer about a thing that is not going to happen.
891
+ *
892
+ * An owner wildcard, or a perpetual licence with no support date, has nothing
893
+ * to show and returns null rather than a chip with no date in it.
894
+ *
895
+ * @param {object} manifest
896
+ * @param {object|null} [given] - injectable for testing; read from disk when omitted
897
+ * @returns {{kind: 'rental'|'support', date: string, state: string}|null}
898
+ */
899
+ export function pluginExpiry(manifest, given) {
900
+ if (manifest?.closedSource !== true) return null;
901
+ const {state, entitlement} = classifyEntitlement(manifest.name, given);
902
+ if (!entitlement || state === 'unlicensed') return null;
903
+ if (entitlement.model === 'rental') {
904
+ return entitlement.expiresAt ? {kind: 'rental', date: entitlement.expiresAt, state} : null;
905
+ }
906
+ return entitlement.supportExpiresAt
907
+ ? {kind: 'support', date: entitlement.supportExpiresAt, state}
908
+ : null;
909
+ }
910
+
616
911
  export async function getAdminPluginConfig() {
617
912
  const manifests = await discoverPlugins();
618
913
  const states = getPluginStates();
@@ -621,6 +916,10 @@ export async function getAdminPluginConfig() {
621
916
  const routes = [];
622
917
  const views = {};
623
918
  const css = [];
919
+ // Per-view plugin identity, so the admin can put the same banner above
920
+ // every plugin screen without each plugin having to render one (and each
921
+ // rendering it differently, which is what it did before).
922
+ const meta = {};
624
923
 
625
924
  for (const manifest of manifests) {
626
925
  const state = states[manifest.name] || {};
@@ -629,27 +928,61 @@ export async function getAdminPluginConfig() {
629
928
  if (manifest.admin.sidebar) sidebar.push(...manifest.admin.sidebar);
630
929
  if (manifest.admin.routes) routes.push(...manifest.admin.routes);
631
930
 
632
- // Auto-stamp view entries with mtime-based version (replaces manual ?v=N)
931
+ // Auto-stamp view entries with an mtime-based version.
932
+ //
933
+ // The stamp is the NEWEST mtime of the entry, the plugin's own admin
934
+ // tree and the shared admin library - not the entry alone. A view is
935
+ // usually a thin wrapper that rarely changes, while the code it
936
+ // imports changes constantly, so stamping only the entry left the
937
+ // browser fetching the same URL and serving the previous build of
938
+ // everything behind it. That presents as a feature that worked
939
+ // yesterday and does not today, on one machine.
940
+ // Once per plugin, not per view: it verifies a signature.
941
+ const expiry = pluginExpiry(manifest);
942
+ const sharedStamp = Math.max(
943
+ await newestAdminMtime(path.join(PLUGINS_DIR, '_lib', 'admin')),
944
+ await newestAdminMtime(path.join(PLUGINS_DIR, manifest.name, 'admin'))
945
+ );
633
946
  for (const [viewName, viewDef] of Object.entries(manifest.admin.views || {})) {
634
947
  const entryBase = viewDef.entry.split('?')[0];
635
948
  let stamped = viewDef.entry;
636
949
  try {
637
950
  const filePath = path.join(PLUGINS_DIR, entryBase);
638
951
  const s = await fs.stat(filePath);
639
- stamped = `${entryBase}?v=${Math.floor(s.mtimeMs)}`;
952
+ stamped = `${entryBase}?v=${Math.max(Math.floor(s.mtimeMs), sharedStamp)}`;
640
953
  } catch { /* keep original entry as-is */ }
641
954
  views[viewName] = { ...viewDef, entry: stamped };
955
+ meta[viewName] = {
956
+ plugin: manifest.name,
957
+ displayName: manifest.displayName || manifest.name,
958
+ version: manifest.version || '1.0.0',
959
+ date: manifest.date || '',
960
+ author: manifest.author || '',
961
+ icon: manifest.icon || 'package',
962
+ core: CORE_PLUGINS.has(manifest.name),
963
+ licence: pluginLicence(manifest),
964
+ expiry
965
+ };
642
966
  }
643
967
 
644
- // Collect admin CSS links declared in plugin.json admin.css[]
968
+ // Collect admin CSS links declared in plugin.json admin.css[], stamped
969
+ // with the file's mtime for the same reason view entries are: a browser
970
+ // keys its cache on the full URL, so an unstamped stylesheet keeps
971
+ // serving the old rules after an update and the plugin looks broken in
972
+ // a way that only a hard refresh explains.
645
973
  for (let i = 0; i < (manifest.admin.css || []).length; i++) {
646
974
  const cssPath = manifest.admin.css[i];
975
+ let href = `/plugins/${manifest.name}/${cssPath}`;
976
+ try {
977
+ const stat = await fs.stat(path.join(PLUGINS_DIR, manifest.name, cssPath));
978
+ href += `?v=${Math.max(Math.floor(stat.mtimeMs), sharedStamp)}`;
979
+ } catch { /* unreadable - link it unstamped rather than not at all */ }
647
980
  css.push({
648
981
  id: `plugin-${manifest.name}-css${i > 0 ? `-${i}` : ''}`,
649
- href: `/plugins/${manifest.name}/${cssPath}`
982
+ href
650
983
  });
651
984
  }
652
985
  }
653
986
 
654
- return { sidebar, routes, views, css };
987
+ return { sidebar, routes, views, css, meta };
655
988
  }