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
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Entitlement verification - may this plugin run here?
3
+ *
4
+ * Only asked of a plugin declaring `closedSource: true`. A plugin the user wrote
5
+ * has no entitlement, needs none, and must never be asked for one: the gate keys
6
+ * on a flag WE set on OUR paid plugins, so the free builder is untouched by all
7
+ * of this.
8
+ *
9
+ * The two commercial models expire in completely different ways, and getting it
10
+ * backwards is the mistake with real consequences:
11
+ *
12
+ * rental a managed site pays monthly. Past its term plus grace, it stops.
13
+ * perpetual bought outright. It NEVER stops. What lapses is support and
14
+ * updates - so a customer who paid a large sum once and declined
15
+ * the renewal keeps what they bought, forever, offline.
16
+ *
17
+ * A wildcard entitlement (`slug: "*"`) satisfies any slug. That is how an owner
18
+ * instance works: the manager is where plugins are written, so a plugin being
19
+ * built today has no slug in any ledger and a per-slug entitlement could not
20
+ * name it.
21
+ *
22
+ * @module server/services/pluginEntitlement
23
+ */
24
+ import {createPublicKey, verify as cryptoVerify} from 'node:crypto';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import {trustedKey} from './pluginInstaller.js';
28
+
29
+ /**
30
+ * Canonical JSON: keys sorted at every depth, no whitespace, undefined dropped.
31
+ *
32
+ * A deliberate verbatim copy of the signer's, in dcms-marketplace's
33
+ * scripts/entitlement.js. The signature is over bytes, and a verifier that
34
+ * disagrees by one space rejects everything for a reason nobody can see. It is
35
+ * duplicated rather than shared because the signer is not, and must not be, a
36
+ * dependency of the CMS.
37
+ *
38
+ * @param {unknown} value
39
+ * @returns {string}
40
+ */
41
+ export function canonicalise(value) {
42
+ if (Array.isArray(value)) {
43
+ return '[' + value.map(canonicalise).join(',') + ']';
44
+ }
45
+ if (value && typeof value === 'object') {
46
+ const keys = Object.keys(value).filter(k => value[k] !== undefined).sort();
47
+ return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalise(value[k])).join(',') + '}';
48
+ }
49
+ return JSON.stringify(value);
50
+ }
51
+
52
+ /** Does this entitlement cover the named plugin? */
53
+ export function coversSlug(entitlement, slug) {
54
+ return entitlement?.slug === '*' || entitlement?.slug === slug;
55
+ }
56
+
57
+ /**
58
+ * Find the entitlement covering a plugin.
59
+ *
60
+ * Two places, in order:
61
+ *
62
+ * 1. `plugins/<slug>/.entitlement.json` - what an envelope carried in. One
63
+ * plugin, one licence, sitting beside the thing it licenses.
64
+ * 2. `config/entitlements/*.json` - INSTANCE level. This is where an owner
65
+ * wildcard lives, and it has to be here rather than in a plugin directory:
66
+ * a wildcard filed under one plugin would cover that plugin only, which
67
+ * defeats the whole point of it.
68
+ *
69
+ * A per-plugin file wins. It is the more specific claim, and it is the one a
70
+ * renewal writes.
71
+ *
72
+ * @param {string} slug
73
+ * @returns {object|null}
74
+ */
75
+ export function readEntitlement(slug) {
76
+ const own = path.join(path.resolve('plugins'), slug, '.entitlement.json');
77
+ try {
78
+ return JSON.parse(fs.readFileSync(own, 'utf8'));
79
+ } catch { /* fall through to the instance level */ }
80
+
81
+ const dir = path.resolve('config', 'entitlements');
82
+ let names;
83
+ try {
84
+ names = fs.readdirSync(dir).filter(n => n.endsWith('.json')).sort();
85
+ } catch {
86
+ return null;
87
+ }
88
+ for (const name of names) {
89
+ try {
90
+ const e = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
91
+ if (coversSlug(e, slug)) return e;
92
+ } catch { /* a malformed file is not an entitlement */ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * Classify a plugin's entitlement.
99
+ *
100
+ * Returns one of:
101
+ * 'valid' load it
102
+ * 'grace' load it, and say so - a rental just past its term
103
+ * 'support-ended' load it - a perpetual licence whose updates have stopped
104
+ * 'expired' do NOT load - a rental past its grace period
105
+ * 'unlicensed' do NOT load - absent, malformed, or not signed by us
106
+ *
107
+ * @param {string} slug
108
+ * @param {object|null} [entitlement] - injectable for testing
109
+ * @param {Date} [now]
110
+ * @returns {{state: string, reason: string, entitlement: object|null}}
111
+ */
112
+ export function classifyEntitlement(slug, entitlement = readEntitlement(slug), now = new Date()) {
113
+ if (!entitlement) {
114
+ return {state: 'unlicensed', reason: 'no entitlement file', entitlement: null};
115
+ }
116
+ if (!coversSlug(entitlement, slug)) {
117
+ return {state: 'unlicensed', reason: `entitlement is for "${entitlement.slug}"`, entitlement};
118
+ }
119
+
120
+ const pem = trustedKey(entitlement.keyId);
121
+ if (!pem) {
122
+ return {state: 'unlicensed', reason: `unknown signing key "${entitlement.keyId}"`, entitlement};
123
+ }
124
+
125
+ const {signature, ...rest} = entitlement;
126
+ let ok = false;
127
+ try {
128
+ ok = cryptoVerify(null, Buffer.from(canonicalise(rest), 'utf8'),
129
+ createPublicKey(pem), Buffer.from(String(signature), 'base64'));
130
+ } catch {
131
+ ok = false;
132
+ }
133
+ if (!ok) return {state: 'unlicensed', reason: 'signature does not verify', entitlement};
134
+
135
+ const ms = now.getTime();
136
+ const graceMs = (Number(entitlement.gracePeriodDays) || 0) * 86_400_000;
137
+
138
+ if (entitlement.model === 'rental') {
139
+ const end = Date.parse(entitlement.expiresAt);
140
+ if (!Number.isFinite(end)) {
141
+ return {state: 'unlicensed', reason: 'rental with no expiry', entitlement};
142
+ }
143
+ if (ms <= end) return {state: 'valid', reason: '', entitlement};
144
+ if (ms <= end + graceMs) return {state: 'grace', reason: 'rental term ended', entitlement};
145
+ return {state: 'expired', reason: 'rental term and grace period ended', entitlement};
146
+ }
147
+
148
+ // Perpetual. Support may have lapsed; the licence has not, and never will.
149
+ const support = Date.parse(entitlement.supportExpiresAt);
150
+ if (Number.isFinite(support) && ms > support) {
151
+ return {state: 'support-ended', reason: 'support and updates ended', entitlement};
152
+ }
153
+ return {state: 'valid', reason: '', entitlement};
154
+ }
155
+
156
+ /** The states that let a plugin load. */
157
+ const LOADABLE = new Set(['valid', 'grace', 'support-ended']);
158
+
159
+ /**
160
+ * May this plugin load?
161
+ *
162
+ * @param {object} manifest
163
+ * @returns {{allowed: boolean, state: string, reason: string}}
164
+ */
165
+ export function mayLoad(manifest) {
166
+ if (manifest?.closedSource !== true) {
167
+ return {allowed: true, state: 'not-required', reason: ''};
168
+ }
169
+ const {state, reason} = classifyEntitlement(manifest.name);
170
+ return {allowed: LOADABLE.has(state), state, reason};
171
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * The `.dcmsplugin` envelope - one file a plugin travels in.
3
+ *
4
+ * A gzipped tar carrying three members at the top level:
5
+ *
6
+ * plugin.tar.gz the plugin directory, the same bytes the catalogue ships
7
+ * plugin.sig detached Ed25519 signature over plugin.tar.gz, base64.
8
+ * OPTIONAL: an export from a running CMS cannot be signed,
9
+ * because the marketplace key is offline and never on a
10
+ * customer's host.
11
+ * entitlement.json OPTIONAL: present on a paid download, absent on an export
12
+ *
13
+ * One format for all three delivery paths - catalogue bundle, paid download, and
14
+ * a plain export between two installations - so every one of them enters through
15
+ * the same installer and gets the same traversal guard, manifest validation and
16
+ * cleanup on failure. A second "just unzip it" path would be the one that skips a
17
+ * check.
18
+ *
19
+ * Tar is written and read here rather than shelled out to, because a CMS is not
20
+ * guaranteed to have the binary and because `extractTar` in pluginInstaller.js
21
+ * deliberately strips the leading path component - correct for a plugin tarball,
22
+ * wrong for an envelope whose members sit at the root. The two readers answer
23
+ * different questions and are kept apart on purpose.
24
+ *
25
+ * @module server/services/pluginEnvelope
26
+ */
27
+ import fs from 'fs/promises';
28
+ import path from 'path';
29
+ import zlib from 'zlib';
30
+
31
+ const BLOCK = 512;
32
+
33
+ /**
34
+ * A ustar header for one member.
35
+ *
36
+ * Names are capped at 100 bytes, which is the format's own limit and not worth
37
+ * the GNU long-name extension for three fixed filenames.
38
+ *
39
+ * @param {string} name
40
+ * @param {number} size
41
+ * @returns {Buffer}
42
+ */
43
+ function header(name, size) {
44
+ if (Buffer.byteLength(name) > 100) throw new Error(`envelope member name too long: ${name}`);
45
+ const h = Buffer.alloc(BLOCK);
46
+ h.write(name, 0, 100, 'utf8');
47
+ h.write('0000644\0', 100); // mode
48
+ h.write('0000000\0', 108); // uid
49
+ h.write('0000000\0', 116); // gid
50
+ h.write(size.toString(8).padStart(11, '0') + '\0', 124);
51
+ // Fixed mtime: the envelope's bytes should depend on its contents and
52
+ // nothing else, so the same plugin exported twice compares equal.
53
+ h.write('00000000000\0', 136);
54
+ h.write(' ', 148); // checksum placeholder
55
+ h.write('0', 156); // type: regular file
56
+ h.write('ustar\0', 257);
57
+ h.write('00', 263);
58
+
59
+ let sum = 0;
60
+ for (const b of h) sum += b;
61
+ h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148);
62
+ return h;
63
+ }
64
+
65
+ /** Pad a buffer up to the next 512-byte boundary. */
66
+ function pad(buf) {
67
+ const over = buf.length % BLOCK;
68
+ return over === 0 ? buf : Buffer.concat([buf, Buffer.alloc(BLOCK - over)]);
69
+ }
70
+
71
+ /**
72
+ * What never travels in an export.
73
+ *
74
+ * `data/` is the site's runtime state, not the plugin's - the same carve-out the
75
+ * npm package and the catalogue build both make. `CLAUDE.md` is working notes.
76
+ */
77
+ export const EXPORT_EXCLUDES = new Set(['data', 'backups', 'node_modules', 'CLAUDE.md', '.DS_Store']);
78
+
79
+ /**
80
+ * Tar a plugin directory into the shape `pluginInstaller.extractTar()` expects:
81
+ * every entry under a single leading component, which that parser strips.
82
+ *
83
+ * Sorted, with a fixed mtime, so the same plugin exported twice produces
84
+ * identical bytes - which is what lets two exports be compared at all.
85
+ *
86
+ * @param {string} dir - absolute plugin directory
87
+ * @param {string} prefix - the leading component, normally the slug
88
+ * @param {{includeData?: boolean}} [opts]
89
+ * @returns {Promise<Buffer>} gzipped tar
90
+ */
91
+ export async function tarDirectory(dir, prefix, {includeData = false} = {}) {
92
+ const files = [];
93
+ async function walk(abs, rel) {
94
+ const entries = await fs.readdir(abs, {withFileTypes: true});
95
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
96
+ const skip = EXPORT_EXCLUDES.has(e.name) && !(includeData && e.name === 'data');
97
+ if (skip) continue;
98
+ const next = rel ? `${rel}/${e.name}` : e.name;
99
+ // Symlinks are neither followed nor recorded: extractTar ignores them
100
+ // on the way in, so carrying one would only ever be a surprise.
101
+ if (e.isSymbolicLink()) continue;
102
+ if (e.isDirectory()) await walk(path.join(abs, e.name), next);
103
+ else if (e.isFile()) files.push([next, await fs.readFile(path.join(abs, e.name))]);
104
+ }
105
+ }
106
+ await walk(dir, '');
107
+
108
+ const chunks = [];
109
+ for (const [rel, body] of files) {
110
+ chunks.push(header(`${prefix}/${rel}`, body.length), pad(body));
111
+ }
112
+ chunks.push(Buffer.alloc(BLOCK * 2));
113
+ return zlib.gzipSync(Buffer.concat(chunks), {level: 9});
114
+ }
115
+
116
+ /**
117
+ * Build an envelope.
118
+ *
119
+ * @param {{tarball: Buffer, signature?: string|null, entitlement?: object|null}} parts
120
+ * @returns {Buffer} gzipped tar
121
+ */
122
+ export function packEnvelope({tarball, signature = null, entitlement = null}) {
123
+ if (!Buffer.isBuffer(tarball) || !tarball.length) {
124
+ throw new Error('packEnvelope: tarball is required');
125
+ }
126
+ const members = [['plugin.tar.gz', tarball]];
127
+ if (signature) members.push(['plugin.sig', Buffer.from(String(signature), 'utf8')]);
128
+ if (entitlement) {
129
+ members.push(['entitlement.json', Buffer.from(JSON.stringify(entitlement, null, 2) + '\n', 'utf8')]);
130
+ }
131
+
132
+ const chunks = [];
133
+ for (const [name, body] of members) {
134
+ chunks.push(header(name, body.length), pad(body));
135
+ }
136
+ chunks.push(Buffer.alloc(BLOCK * 2)); // end-of-archive
137
+ return zlib.gzipSync(Buffer.concat(chunks), {level: 9});
138
+ }
139
+
140
+ /**
141
+ * Read the manifest out of a plugin tarball, without extracting it.
142
+ *
143
+ * The slug an upload installs under comes from here, never from the uploaded
144
+ * filename: a file can be renamed by anyone, a manifest is the thing the
145
+ * installer then validates against what was extracted.
146
+ *
147
+ * @param {Buffer} tarball - gzipped tar with a single leading component
148
+ * @returns {object|null} the parsed manifest, or null if there is not one
149
+ */
150
+ export function readManifestFromTarball(tarball) {
151
+ let raw;
152
+ try {
153
+ raw = zlib.gunzipSync(tarball);
154
+ } catch {
155
+ return null;
156
+ }
157
+ let offset = 0;
158
+ let zeros = 0;
159
+ while (offset + BLOCK <= raw.length) {
160
+ const h = raw.subarray(offset, offset + BLOCK);
161
+ if (h.every(b => b === 0)) {
162
+ if (++zeros >= 2) break;
163
+ offset += BLOCK;
164
+ continue;
165
+ }
166
+ zeros = 0;
167
+ offset += BLOCK;
168
+ const nul = h.indexOf(0);
169
+ const name = h.subarray(0, nul === -1 ? 100 : Math.min(nul, 100)).toString('utf8');
170
+ const size = parseInt(h.subarray(124, 136).toString('utf8').replace(/\0/g, '').trim(), 8) || 0;
171
+ // `<slug>/plugin.json` and nothing deeper - a plugin.json further down
172
+ // the tree belongs to something else.
173
+ if (/^[^/]+\/plugin\.json$/.test(name)) {
174
+ try {
175
+ return JSON.parse(raw.subarray(offset, offset + size).toString('utf8'));
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+ offset += Math.ceil(size / BLOCK) * BLOCK;
181
+ }
182
+ return null;
183
+ }
184
+
185
+ /**
186
+ * Read an envelope.
187
+ *
188
+ * Refuses anything it does not recognise rather than guessing: an envelope is
189
+ * our own format, so a member we did not write is a sign the file is not what
190
+ * the caller thinks it is.
191
+ *
192
+ * @param {Buffer} buffer - the .dcmsplugin file
193
+ * @returns {{tarball: Buffer, signature: string|null, entitlement: object|null}}
194
+ */
195
+ export function readEnvelope(buffer) {
196
+ let raw;
197
+ try {
198
+ raw = zlib.gunzipSync(buffer);
199
+ } catch {
200
+ throw new Error('Not a .dcmsplugin file: it is not gzipped');
201
+ }
202
+
203
+ const found = new Map();
204
+ let offset = 0;
205
+ let zeros = 0;
206
+ while (offset + BLOCK <= raw.length) {
207
+ const h = raw.subarray(offset, offset + BLOCK);
208
+ if (h.every(b => b === 0)) {
209
+ if (++zeros >= 2) break;
210
+ offset += BLOCK;
211
+ continue;
212
+ }
213
+ zeros = 0;
214
+ offset += BLOCK;
215
+
216
+ const nul = h.indexOf(0);
217
+ const name = h.subarray(0, nul === -1 ? 100 : Math.min(nul, 100)).toString('utf8');
218
+ const size = parseInt(h.subarray(124, 136).toString('utf8').replace(/\0/g, '').trim(), 8) || 0;
219
+ // Members sit at the root - no directories, no nesting. Anything else is
220
+ // not an envelope we wrote.
221
+ if (name && !name.includes('/')) found.set(name, raw.subarray(offset, offset + size));
222
+ offset += Math.ceil(size / BLOCK) * BLOCK;
223
+ }
224
+
225
+ const tarball = found.get('plugin.tar.gz');
226
+ if (!tarball) throw new Error('Not a .dcmsplugin file: no plugin.tar.gz inside');
227
+
228
+ let entitlement = null;
229
+ if (found.has('entitlement.json')) {
230
+ try {
231
+ entitlement = JSON.parse(found.get('entitlement.json').toString('utf8'));
232
+ } catch {
233
+ throw new Error('The entitlement inside this file is not readable JSON');
234
+ }
235
+ }
236
+
237
+ return {
238
+ tarball: Buffer.from(tarball),
239
+ signature: found.has('plugin.sig') ? found.get('plugin.sig').toString('utf8').trim() : null,
240
+ entitlement
241
+ };
242
+ }
@@ -7,8 +7,16 @@
7
7
  * valid names), paths are normalised and containment-checked after symlink
8
8
  * resolution, and only known text extensions are editable.
9
9
  *
10
- * Thrown errors carry `statusCode` (400 invalid, 404 missing, 413 too large)
11
- * for direct mapping in the route layer.
10
+ * A plugin marked `"closedSource": true` in its manifest is refused - licensed
11
+ * code is distributed to a site so it can RUN there, not so it can be read out
12
+ * of it, and the editor was handing over the server source in full. The one
13
+ * exception is a SUPER-ADMIN (role level 0), which on the fleet exists only on
14
+ * the manager: the people who publish the paid plugins are the people who need
15
+ * to read and edit them. Customer sites are all level 1, so nothing changes
16
+ * there - see the `allowLicensed` argument, set from the request's user.
17
+ *
18
+ * Thrown errors carry `statusCode` (400 invalid, 403 licensed, 404 missing,
19
+ * 413 too large) for direct mapping in the route layer.
12
20
  */
13
21
  import fs from 'fs/promises';
14
22
  import path from 'path';
@@ -40,7 +48,7 @@ function fail(statusCode, message) {
40
48
  * @param {string} name
41
49
  * @returns {Promise<string>} Absolute plugin directory
42
50
  */
43
- async function resolvePluginDir(name) {
51
+ async function resolvePluginDir(name, allowLicensed = false) {
44
52
  if (!NAME_PATTERN.test(String(name || ''))) throw fail(400, 'Invalid plugin name.');
45
53
  const dir = path.join(PLUGINS_DIR, name);
46
54
  try {
@@ -49,9 +57,42 @@ async function resolvePluginDir(name) {
49
57
  } catch {
50
58
  throw fail(404, `Plugin "${name}" not found.`);
51
59
  }
60
+
61
+ // Every operation in this module goes through here, which is why the check
62
+ // lives here and not in four places that could drift apart.
63
+ if (!allowLicensed && await isClosedSource(dir)) {
64
+ throw fail(403, `"${name}" is a licensed plugin. Its source is not editable here.`);
65
+ }
52
66
  return dir;
53
67
  }
54
68
 
69
+ /**
70
+ * Is this plugin's source off limits to the code editor?
71
+ *
72
+ * A licensed plugin is distributed to a site so it can RUN there, not so it
73
+ * can be read and copied - and the editor made it readable AND writable to
74
+ * anyone holding `plugins.develop`, which on a customer's own site is the
75
+ * customer. Writable is the worse half: the licence check is in that file.
76
+ *
77
+ * Declared by the plugin itself (`"closedSource": true` in plugin.json) so the
78
+ * flag travels with it wherever it is distributed, rather than depending on a
79
+ * list somewhere that a new paid plugin would have to be remembered into.
80
+ *
81
+ * A manifest that cannot be read is treated as closed. This gate failing open
82
+ * would publish the very thing it exists to protect.
83
+ *
84
+ * @param {string} dir - absolute plugin directory
85
+ * @returns {Promise<boolean>}
86
+ */
87
+ async function isClosedSource(dir) {
88
+ try {
89
+ const manifest = JSON.parse(await fs.readFile(path.join(dir, 'plugin.json'), 'utf8'));
90
+ return manifest.closedSource === true;
91
+ } catch {
92
+ return true;
93
+ }
94
+ }
95
+
55
96
  /**
56
97
  * Resolve a relative file path inside the plugin dir, or throw.
57
98
  * Rejects absolute paths, traversal, hidden segments and unknown extensions.
@@ -98,8 +139,8 @@ async function resolveFilePath(pluginDir, relPath) {
98
139
  * @param {string} name
99
140
  * @returns {Promise<Array<{path: string, size: number, modified: string}>>}
100
141
  */
101
- export async function listPluginFiles(name) {
102
- const pluginDir = await resolvePluginDir(name);
142
+ export async function listPluginFiles(name, {allowLicensed = false} = {}) {
143
+ const pluginDir = await resolvePluginDir(name, allowLicensed);
103
144
  const files = [];
104
145
 
105
146
  async function walk(dir) {
@@ -132,8 +173,8 @@ export async function listPluginFiles(name) {
132
173
  * @param {string} relPath
133
174
  * @returns {Promise<{path: string, content: string}>}
134
175
  */
135
- export async function readPluginFile(name, relPath) {
136
- const pluginDir = await resolvePluginDir(name);
176
+ export async function readPluginFile(name, relPath, {allowLicensed = false} = {}) {
177
+ const pluginDir = await resolvePluginDir(name, allowLicensed);
137
178
  const abs = await resolveFilePath(pluginDir, relPath);
138
179
 
139
180
  let stat;
@@ -158,8 +199,8 @@ export async function readPluginFile(name, relPath) {
158
199
  * @param {string} content
159
200
  * @returns {Promise<{path: string, restartRequired: boolean}>}
160
201
  */
161
- export async function writePluginFile(name, relPath, content) {
162
- const pluginDir = await resolvePluginDir(name);
202
+ export async function writePluginFile(name, relPath, content, {allowLicensed = false} = {}) {
203
+ const pluginDir = await resolvePluginDir(name, allowLicensed);
163
204
  const abs = await resolveFilePath(pluginDir, relPath);
164
205
 
165
206
  if (typeof content !== 'string') throw fail(400, 'Content must be a string.');
@@ -198,8 +239,8 @@ export async function writePluginFile(name, relPath, content) {
198
239
  * @param {string} relPath
199
240
  * @returns {Promise<{path: string}>}
200
241
  */
201
- export async function deletePluginFile(name, relPath) {
202
- const pluginDir = await resolvePluginDir(name);
242
+ export async function deletePluginFile(name, relPath, {allowLicensed = false} = {}) {
243
+ const pluginDir = await resolvePluginDir(name, allowLicensed);
203
244
  const abs = await resolveFilePath(pluginDir, relPath);
204
245
  const rel = path.relative(pluginDir, abs).split(path.sep).join('/');
205
246