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
@@ -15,7 +15,9 @@ import fs from 'fs/promises';
15
15
  import path from 'path';
16
16
  import zlib from 'zlib';
17
17
  import {fileURLToPath} from 'url';
18
- import {discoverPlugins} from './plugins.js';
18
+ import {getConfig} from '../config.js';
19
+ import {discoverPlugins, validatePluginDir} from './plugins.js';
20
+ import {allowInGitignore, manifestContentPaths, pluginContentRules} from './siteGitignore.js';
19
21
 
20
22
  // ---------------------------------------------------------------------------
21
23
  // Paths
@@ -27,26 +29,100 @@ const CONFIG_PATH = path.join(ROOT, 'config', 'plugins.json');
27
29
  const AUDIT_LOG = path.join(ROOT, 'content', 'instances_log.json');
28
30
 
29
31
  // ---------------------------------------------------------------------------
30
- // Trusted public keys - rotate by adding new keyId + bumping CMS version
31
- // Private key counterpart: domma-cms-manager/config/keys/plugin-signing-2026.pem
32
+ // Trusted public keys
33
+ //
34
+ // Ed25519. Rotation is pinned by CMS version: a release trusts the keys listed
35
+ // here and nothing else, so a bundle signed by a key this version has never
36
+ // heard of is refused by keyId before its signature is even checked.
37
+ //
38
+ // `manager-2026` was REMOVED rather than retained. Its private half could not be
39
+ // found, and a key we cannot find is not one we know to be destroyed - leaving
40
+ // its public half here would mean anyone who does have it can sign a bundle
41
+ // every CMS accepts as ours, permanently. Nothing had ever been signed with it,
42
+ // so there was no artifact to stay compatible with.
43
+ //
44
+ // Private counterpart: generated 2026-09-22, held off-repo. See the marketplace
45
+ // design's Signing keys section for custody.
32
46
  // ---------------------------------------------------------------------------
33
47
 
34
48
  const TRUSTED_KEYS = {
35
- 'manager-2026': `-----BEGIN PUBLIC KEY-----
36
- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArmEogar77p4RwljMgEsi
37
- zSJRXIvRe8LDNzHvoez9gOu0SYUd2O8nsDOiLohsPxhH5+c8eXdBOkH+1y1O5QaN
38
- TmZTRGrRR0NrxhPVTT7trtJIiXH4IwgwWgzYVmCKOdKqX4wj2JwGbiPE57ubOBKE
39
- 4T66OUIZmezSYYkhPmWAUCWmCX9KxL6czbMtNZYIn9U2O04imD/VtBieB4xCZy0P
40
- U9vE3Uz4bcjLjjo70+oWXtAuudlLUiMREJKcT6J72CZSW6rzuR1+NDJsh4q5C6jd
41
- e+B83PU+WiFxNwoHHJ91myEY4vDDWDDYV8G+sS/4QzBOgL9StwwvQzBuriWKjl8J
42
- eQIDAQAB
49
+ 'marketplace-1': `-----BEGIN PUBLIC KEY-----
50
+ MCowBQYDK2VwAyEAkgYWv0IckLy2lCeNoaNot++KMJLpJa6FTd6RW0sH2zg=
43
51
  -----END PUBLIC KEY-----`
44
52
  };
45
53
 
54
+ /**
55
+ * Does this site refuse anything the marketplace has not signed?
56
+ *
57
+ * Default false, because a CMS whose own scaffolder produces unsigned plugins
58
+ * cannot refuse unsigned plugins without refusing its user's own work. A fleet
59
+ * site should set it true: everything it receives arrives by push-copy, and
60
+ * nothing legitimate is ever uploaded there.
61
+ *
62
+ * @returns {boolean}
63
+ */
64
+ function requireSignedPlugins() {
65
+ try {
66
+ return getConfig('server')?.requireSignedPlugins === true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * One trusted public key by id, or null.
74
+ *
75
+ * Entitlement verification needs the same keys bundle installation does, and a
76
+ * second copy of them would be a second thing to rotate.
77
+ *
78
+ * @param {string} keyId
79
+ * @returns {string|null} PEM
80
+ */
81
+ export function trustedKey(keyId) {
82
+ return TRUSTED_KEYS[keyId] ?? null;
83
+ }
84
+
85
+ /**
86
+ * The keyIds this CMS version trusts.
87
+ *
88
+ * Exported so the contract is testable without exposing key material: a release
89
+ * trusting a key it should not, or having lost the one it should, is the kind of
90
+ * mistake that is invisible until a bundle is refused in the field.
91
+ *
92
+ * @returns {string[]}
93
+ */
94
+ export function trustedKeyIds() {
95
+ return Object.keys(TRUSTED_KEYS);
96
+ }
97
+
46
98
  // ---------------------------------------------------------------------------
47
99
  // Internal helpers
48
100
  // ---------------------------------------------------------------------------
49
101
 
102
+ /**
103
+ * Compare two dotted version strings. -1, 0 or 1.
104
+ *
105
+ * Deliberately not semver-complete: it compares numeric segments and ignores
106
+ * pre-release tags. Plugin versions here are plain x.y.z, and pulling in a
107
+ * semver dependency to compare three integers would be the worse trade. A
108
+ * segment that is not a number sorts as 0, so a nonsense version loses to a
109
+ * real one rather than throwing.
110
+ *
111
+ * @param {string} a
112
+ * @param {string} b
113
+ * @returns {number}
114
+ */
115
+ export function compareVersions(a, b) {
116
+ const parse = v => String(v ?? '').split('.').map(x => parseInt(x, 10) || 0);
117
+ const av = parse(a);
118
+ const bv = parse(b);
119
+ for (let i = 0; i < Math.max(av.length, bv.length); i++) {
120
+ const d = (av[i] ?? 0) - (bv[i] ?? 0);
121
+ if (d !== 0) return d > 0 ? 1 : -1;
122
+ }
123
+ return 0;
124
+ }
125
+
50
126
  /**
51
127
  * Append a single record to the audit log, creating the file if absent.
52
128
  *
@@ -167,33 +243,61 @@ async function extractTar(tarBuffer, slug, destDir) {
167
243
  * 6. Run onEnable lifecycle hook (warn-only on failure)
168
244
  * 7. Append install record to audit log
169
245
  *
246
+ * A bundle from the marketplace is signed. One exported from a running CMS
247
+ * cannot be - the signing key is offline and never on a customer's host - so an
248
+ * UNSIGNED bundle is accepted only when the caller has explicitly said so and
249
+ * the site has not set `requireSignedPlugins`. A bundle carrying a signature
250
+ * that does not verify is always refused: a broken signature means tampering,
251
+ * never "unsigned".
252
+ *
170
253
  * @param {string} slug
171
- * @param {{ tarball: Buffer, signature: string, publicKeyId: string }} options
254
+ * @param {{ tarball: Buffer, signature?: string|null, publicKeyId?: string|null,
255
+ * allowUnsigned?: boolean, entitlement?: object|null,
256
+ * allowUpgrade?: boolean, allowDowngrade?: boolean }} options
172
257
  * @returns {Promise<void>}
258
+ * @returns {Promise<{slug: string, action: string, from: string|null, version: string}>}
173
259
  * @throws {Error} If signature is invalid, slug is already installed, or manifest validation fails
174
260
  */
175
- export async function installPlugin(slug, { tarball, signature, publicKeyId }) {
261
+ /**
262
+ * A distributed plugin is ignored by the site's .gitignore, but one that keeps
263
+ * what users AUTHOR inside its own folder (plugin.json `contentPaths`) must
264
+ * have those folders tracked, or the site repo silently loses that work.
265
+ */
266
+ function trackPluginContent(slug, manifest) {
267
+ const rules = manifestContentPaths(manifest).flatMap(rel => pluginContentRules(slug, rel));
268
+ if (rules.length) allowInGitignore(ROOT, rules);
269
+ }
270
+
271
+ export async function installPlugin(slug, { tarball, signature, publicKeyId, allowUnsigned = false, entitlement = null, allowUpgrade = false, allowDowngrade = false }) {
176
272
  if (!/^[a-z0-9][a-z0-9-_]{0,63}$/.test(slug)) {
177
273
  throw new Error(`Invalid plugin slug: ${slug}`);
178
274
  }
179
275
 
180
- // Step 1 - Verify signature
181
- const pubKeyPem = TRUSTED_KEYS[publicKeyId];
182
- if (!pubKeyPem) {
183
- throw new Error(`Unknown public key: ${publicKeyId}`);
276
+ // Step 1 - Verify signature, or establish that there is deliberately none.
277
+ const unsigned = !signature;
278
+ if (unsigned) {
279
+ if (!allowUnsigned) {
280
+ throw new Error('This plugin is unsigned. Installing it runs its code on this server, so it has to be confirmed.');
281
+ }
282
+ if (requireSignedPlugins()) {
283
+ throw new Error('This site accepts signed plugins only (server.json: requireSignedPlugins).');
284
+ }
184
285
  }
185
286
 
186
- if (pubKeyPem.includes('PLACEHOLDER')) {
187
- throw new Error(`pluginInstaller: TRUSTED_KEYS['${publicKeyId}'] is a placeholder - not usable for verification`);
287
+ const pubKeyPem = unsigned ? null : TRUSTED_KEYS[publicKeyId];
288
+ if (!unsigned && !pubKeyPem) {
289
+ throw new Error(`Unknown public key: ${publicKeyId}`);
188
290
  }
189
291
 
190
- const signatureBuffer = Buffer.from(signature, 'base64');
191
- const valid = crypto.verify('sha256', tarball, pubKeyPem, signatureBuffer);
192
- if (!valid) {
292
+ const signatureBuffer = unsigned ? null : Buffer.from(signature, 'base64');
293
+ // Ed25519 takes no digest argument - the algorithm is implied by the key.
294
+ // Passing one throws ERR_OSSL_INVALID_DIGEST rather than degrading quietly,
295
+ // which is the property that makes a half-finished edit here fail loudly.
296
+ if (!unsigned && !crypto.verify(null, tarball, pubKeyPem, signatureBuffer)) {
193
297
  throw new Error('Plugin signature invalid - refusing to install');
194
298
  }
195
299
 
196
- // Step 2 - Reject if already installed
300
+ // Step 2 - Already installed? Either refuse, or upgrade in place.
197
301
  const destDir = path.join(PLUGINS_DIR, slug);
198
302
  let alreadyExists = false;
199
303
  try {
@@ -202,9 +306,12 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId }) {
202
306
  } catch {
203
307
  // Not present - proceed
204
308
  }
205
- if (alreadyExists) {
309
+ if (alreadyExists && !allowUpgrade) {
206
310
  throw new Error(`Plugin already installed: ${slug}`);
207
311
  }
312
+ if (alreadyExists) {
313
+ return upgradePlugin(slug, {tarball, unsigned, entitlement, allowDowngrade});
314
+ }
208
315
 
209
316
  // Step 3 - Decompress + extract
210
317
  await fs.mkdir(destDir, { recursive: true });
@@ -243,13 +350,126 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId }) {
243
350
  console.warn(`[pluginInstaller] onEnable hook for "${slug}" failed: ${err.message}`);
244
351
  }
245
352
 
246
- // Step 7 - Audit log
353
+ // Step 7 - Entitlement, if the envelope carried one. Written beside the
354
+ // plugin so it travels with it and a renewal is a file drop.
355
+ if (entitlement) {
356
+ await fs.writeFile(path.join(destDir, '.entitlement.json'),
357
+ JSON.stringify(entitlement, null, 2) + '\n', 'utf8');
358
+ }
359
+
360
+ // Step 8 - Audit log. An unsigned install is recorded as such: it is the one
361
+ // that was a judgement call rather than a verified fact.
247
362
  await writeAuditLog({
248
- action: 'install',
363
+ action: unsigned ? 'install:unsigned' : 'install',
249
364
  slug,
250
365
  version: manifest.version,
251
366
  timestamp: new Date().toISOString()
252
367
  });
368
+
369
+ trackPluginContent(slug, manifest);
370
+
371
+ return {slug, action: 'install', from: null, version: manifest.version};
372
+ }
373
+
374
+ /**
375
+ * Upgrade a plugin already installed.
376
+ *
377
+ * The order is the whole point. Extract and validate the NEW version before the
378
+ * old one is disturbed, so a broken upgrade is refused with the working
379
+ * installation still in place. Only then swap, and only then delete the backup.
380
+ *
381
+ * Runtime `data/` moves across rather than being replaced: it belongs to the
382
+ * site, not to the version. A plugin that kept its notes in data/ and lost them
383
+ * on every update would be worse than one that could not update at all.
384
+ *
385
+ * On any failure after the swap begins, the previous directory goes back. The
386
+ * one state this must never produce is "neither version installed".
387
+ *
388
+ * @param {string} slug
389
+ * @param {{tarball: Buffer, unsigned: boolean, entitlement: object|null, allowDowngrade: boolean}} opts
390
+ * @returns {Promise<{slug: string, action: string, from: string|null, version: string}>}
391
+ */
392
+ async function upgradePlugin(slug, {tarball, unsigned, entitlement, allowDowngrade}) {
393
+ const destDir = path.join(PLUGINS_DIR, slug);
394
+ const incoming = path.join(PLUGINS_DIR, `.${slug}.incoming`);
395
+ const backup = path.join(PLUGINS_DIR, `.${slug}.backup`);
396
+
397
+ const current = await validatePluginDir(destDir, slug);
398
+
399
+ // 1. Extract the new version somewhere harmless.
400
+ await fs.rm(incoming, {recursive: true, force: true});
401
+ await fs.mkdir(incoming, {recursive: true});
402
+ try {
403
+ await extractTar(zlib.gunzipSync(tarball), slug, incoming);
404
+ } catch (err) {
405
+ await fs.rm(incoming, {recursive: true, force: true});
406
+ throw err;
407
+ }
408
+
409
+ // 2. Validate it BEFORE anything is disturbed.
410
+ const next = await validatePluginDir(incoming, slug);
411
+ if (!next.ok) {
412
+ await fs.rm(incoming, {recursive: true, force: true});
413
+ throw new Error(`Refusing to upgrade ${slug}: the new version is ${next.reason}`);
414
+ }
415
+
416
+ const from = current.manifest?.version;
417
+ const to = next.manifest.version;
418
+ if (current.ok && compareVersions(to, from) < 0 && !allowDowngrade) {
419
+ await fs.rm(incoming, {recursive: true, force: true});
420
+ throw new Error(`Refusing to downgrade ${slug} from ${from} to ${to}.`);
421
+ }
422
+
423
+ // 3. Carry the site's data across.
424
+ const dataDir = path.join(destDir, 'data');
425
+ let hasData = false;
426
+ try {
427
+ await fs.access(dataDir);
428
+ hasData = true;
429
+ } catch { /* nothing to carry */ }
430
+ if (hasData) {
431
+ await fs.rm(path.join(incoming, 'data'), {recursive: true, force: true});
432
+ await fs.cp(dataDir, path.join(incoming, 'data'), {recursive: true});
433
+ }
434
+ // The entitlement belongs to the installation, not the version - a renewal
435
+ // dropped in yesterday must survive today's update.
436
+ const entFile = path.join(destDir, '.entitlement.json');
437
+ let carriedEntitlement = null;
438
+ try {
439
+ carriedEntitlement = await fs.readFile(entFile, 'utf8');
440
+ } catch { /* none */ }
441
+
442
+ // 4. Swap. From here a failure must put the old version back.
443
+ await fs.rm(backup, {recursive: true, force: true});
444
+ await fs.rename(destDir, backup);
445
+ try {
446
+ await fs.rename(incoming, destDir);
447
+ } catch (err) {
448
+ await fs.rename(backup, destDir).catch(() => {});
449
+ await fs.rm(incoming, {recursive: true, force: true});
450
+ throw new Error(`Upgrade of ${slug} failed and the previous version was restored: ${err.message}`);
451
+ }
452
+
453
+ if (entitlement) {
454
+ await fs.writeFile(path.join(destDir, '.entitlement.json'),
455
+ JSON.stringify(entitlement, null, 2) + '\n', 'utf8');
456
+ } else if (carriedEntitlement !== null) {
457
+ await fs.writeFile(path.join(destDir, '.entitlement.json'), carriedEntitlement, 'utf8');
458
+ }
459
+
460
+ await fs.rm(backup, {recursive: true, force: true});
461
+
462
+ await writeAuditLog({
463
+ action: unsigned ? 'upgrade:unsigned' : 'upgrade',
464
+ slug,
465
+ from: from ?? null,
466
+ version: to,
467
+ timestamp: new Date().toISOString()
468
+ });
469
+
470
+ trackPluginContent(slug, next.manifest);
471
+
472
+ return {slug, action: 'upgrade', from: from ?? null, version: to};
253
473
  }
254
474
 
255
475
  /**
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import fs from 'fs/promises';
13
13
  import path from 'path';
14
+ import {allowInGitignore, sitePluginRules} from './siteGitignore.js';
14
15
 
15
16
  const PLUGINS_DIR = path.resolve('plugins');
16
17
  const TEMPLATE_DIR = path.join(PLUGINS_DIR, '_template');
@@ -132,5 +133,9 @@ export async function scaffoldPlugin(input) {
132
133
  await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
133
134
  }
134
135
 
136
+ // A plugin written here is the site's own code, not the updater's, so it
137
+ // belongs in the site repo. The allowlist ignores plugins/* by default.
138
+ allowInGitignore(path.dirname(PLUGINS_DIR), sitePluginRules(slug));
139
+
135
140
  return { name: slug, files };
136
141
  }