domma-cms 0.66.0 → 0.69.4

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 (221) hide show
  1. package/CLAUDE.md +64 -1
  2. package/README.md +14 -10
  3. package/admin/css/admin.css +1 -1
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/lib/analytics-shape.js +1 -0
  8. package/admin/js/lib/contacts-arrange.js +1 -0
  9. package/admin/js/lib/help-popover.js +1 -0
  10. package/admin/js/lib/notes-arrange.js +1 -0
  11. package/admin/js/lib/plugin-accent.js +1 -1
  12. package/admin/js/lib/plugin-chrome.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.js +1 -1
  14. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/todo-arrange.js +1 -0
  17. package/admin/js/lib/tool-kit.js +1 -0
  18. package/admin/js/templates/analytics.html +138 -0
  19. package/admin/js/templates/contacts.html +156 -0
  20. package/admin/js/templates/notes.html +137 -0
  21. package/admin/js/templates/plugin-marketplace.html +17 -4
  22. package/admin/js/templates/plugins.html +13 -7
  23. package/admin/js/templates/theme.html +38 -0
  24. package/admin/js/templates/todo.html +113 -0
  25. package/admin/js/views/analytics.js +62 -0
  26. package/admin/js/views/contacts.js +174 -0
  27. package/admin/js/views/index.js +1 -1
  28. package/admin/js/views/notes.js +104 -0
  29. package/admin/js/views/plugin-marketplace.js +1 -1
  30. package/admin/js/views/plugins.js +24 -22
  31. package/admin/js/views/theme.js +1 -1
  32. package/admin/js/views/todo.js +96 -0
  33. package/bin/cli.js +4 -10
  34. package/bin/lib/plugin-version.js +28 -0
  35. package/bin/update.js +14 -0
  36. package/config/menus/admin-sidebar.json +1 -1
  37. package/config/server.json +3 -1
  38. package/package.json +3 -7
  39. package/plugins/_lib/admin/mail/reader-view.js +20 -0
  40. package/plugins/_lib/mail/store.js +21 -1
  41. package/plugins/blog/CLAUDE.md +237 -0
  42. package/plugins/blog/admin/css/index.css +178 -1
  43. package/plugins/blog/admin/templates/blog.html +35 -53
  44. package/plugins/blog/admin/templates/categories.html +19 -6
  45. package/plugins/blog/admin/templates/comments.html +26 -9
  46. package/plugins/blog/admin/templates/post-editor.html +36 -13
  47. package/plugins/blog/admin/templates/settings.html +2 -11
  48. package/plugins/blog/admin/views/blog.js +288 -162
  49. package/plugins/blog/admin/views/categories.js +212 -201
  50. package/plugins/blog/admin/views/comments.js +207 -158
  51. package/plugins/blog/admin/views/kit.js +372 -0
  52. package/plugins/blog/admin/views/post-editor.js +269 -21
  53. package/plugins/blog/admin/views/settings-cog.js +56 -0
  54. package/plugins/blog/admin/views/settings.js +16 -86
  55. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  56. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  57. package/plugins/blog/blocks/blog-card-row.css +22 -0
  58. package/plugins/blog/blocks/blog-card-row.html +11 -0
  59. package/plugins/blog/blocks/blog-card.css +35 -0
  60. package/plugins/blog/blocks/blog-card.html +16 -0
  61. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  62. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  63. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  64. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  65. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  66. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  67. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  68. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  69. package/plugins/blog/collections/posts/schema.json +3 -1
  70. package/plugins/blog/config.js +6 -0
  71. package/plugins/blog/lib/layouts.js +304 -0
  72. package/plugins/blog/lib/page.js +158 -0
  73. package/plugins/blog/lib/render.js +119 -0
  74. package/plugins/blog/lib/samples.js +234 -0
  75. package/plugins/blog/plugin.js +225 -15
  76. package/plugins/blog/plugin.json +21 -81
  77. package/plugins/blog/plugin.public.js +115 -210
  78. package/plugins/blog/public/blog.css +177 -0
  79. package/plugins/blog/public/blog.js +586 -0
  80. package/plugins/blog/public/samples/aurora.svg +7 -0
  81. package/plugins/blog/public/samples/dusk.svg +7 -0
  82. package/plugins/blog/public/samples/ember.svg +7 -0
  83. package/plugins/blog/public/samples/harbour.svg +7 -0
  84. package/plugins/blog/public/samples/meadow.svg +7 -0
  85. package/plugins/blog/public/samples/rosewood.svg +7 -0
  86. package/plugins/blog/templates/index.html +9 -8
  87. package/plugins/blog/templates/post.html +4 -17
  88. package/plugins/blog/tests/layouts.test.js +97 -0
  89. package/plugins/blog/tests/public.test.js +71 -0
  90. package/plugins/free-tier.lock.json +74 -0
  91. package/plugins/mail-reader/CLAUDE.md +78 -0
  92. package/plugins/mail-reader/plugin.json +2 -1
  93. package/public/css/theme-switcher.css +1 -0
  94. package/public/js/analytics.js +1 -0
  95. package/public/js/theme-switcher.js +1 -0
  96. package/scripts/build.js +41 -2
  97. package/scripts/run-tests.mjs +94 -0
  98. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  99. package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
  100. package/server/routes/api/dashboard.js +8 -2
  101. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  102. package/server/routes/api/plugin-marketplace.js +78 -23
  103. package/server/routes/api/plugins.js +148 -3
  104. package/server/routes/api/todo.js +178 -0
  105. package/server/server.js +61 -1
  106. package/server/services/collections.js +13 -3
  107. package/server/services/managerClient.js +72 -136
  108. package/server/services/markdown.js +54 -1
  109. package/server/services/permissionRegistry.js +50 -0
  110. package/server/services/pluginEntitlement.js +171 -0
  111. package/server/services/pluginEnvelope.js +242 -0
  112. package/server/services/pluginInstaller.js +246 -26
  113. package/server/services/pluginScaffold.js +5 -0
  114. package/server/services/plugins.js +117 -7
  115. package/server/services/presetCollections.js +344 -0
  116. package/server/services/promoted-plugins-migration.js +254 -0
  117. package/server/services/publicCors.js +46 -0
  118. package/server/services/renderer.js +83 -3
  119. package/server/services/sidebarBadges.js +37 -0
  120. package/server/services/siteGitignore.js +310 -0
  121. package/server/services/themeSettings.js +39 -0
  122. package/config/theme.json +0 -18
  123. package/plugins/analytics/admin/css/index.css +0 -1
  124. package/plugins/analytics/admin/templates/analytics.html +0 -70
  125. package/plugins/analytics/admin/views/analytics.js +0 -176
  126. package/plugins/analytics/config.js +0 -14
  127. package/plugins/analytics/plugin.json +0 -60
  128. package/plugins/analytics/public/inject-body.html +0 -60
  129. package/plugins/analytics/public/inject-head.html +0 -1
  130. package/plugins/blog/templates/author.html +0 -9
  131. package/plugins/blog/templates/category.html +0 -9
  132. package/plugins/blog/templates/tag.html +0 -9
  133. package/plugins/contacts/admin/templates/contacts.html +0 -254
  134. package/plugins/contacts/admin/views/contacts.js +0 -943
  135. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  136. package/plugins/contacts/collections/user-contacts/schema.json +0 -179
  137. package/plugins/contacts/config.js +0 -6
  138. package/plugins/contacts/plugin.json +0 -36
  139. package/plugins/demo-viewer/config.js +0 -4
  140. package/plugins/demo-viewer/plugin.js +0 -36
  141. package/plugins/demo-viewer/plugin.json +0 -9
  142. package/plugins/invoice/admin/css/index.css +0 -1
  143. package/plugins/invoice/admin/templates/editor.html +0 -220
  144. package/plugins/invoice/admin/templates/index.html +0 -173
  145. package/plugins/invoice/admin/templates/issuers.html +0 -2
  146. package/plugins/invoice/admin/templates/receivers.html +0 -2
  147. package/plugins/invoice/admin/views/contacts-source.js +0 -266
  148. package/plugins/invoice/admin/views/editor.js +0 -434
  149. package/plugins/invoice/admin/views/export.js +0 -199
  150. package/plugins/invoice/admin/views/help-content.js +0 -61
  151. package/plugins/invoice/admin/views/index.js +0 -643
  152. package/plugins/invoice/admin/views/issuers.js +0 -30
  153. package/plugins/invoice/admin/views/media.js +0 -172
  154. package/plugins/invoice/admin/views/party-view.js +0 -386
  155. package/plugins/invoice/admin/views/payments.js +0 -127
  156. package/plugins/invoice/admin/views/print.js +0 -130
  157. package/plugins/invoice/admin/views/receivers.js +0 -55
  158. package/plugins/invoice/admin/views/send.js +0 -212
  159. package/plugins/invoice/admin/views/settings.js +0 -594
  160. package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
  161. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
  162. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
  163. package/plugins/invoice/collections/invoices/schema.json +0 -33
  164. package/plugins/invoice/config.js +0 -37
  165. package/plugins/invoice/pdf.js +0 -164
  166. package/plugins/invoice/plugin.js +0 -1456
  167. package/plugins/invoice/plugin.json +0 -86
  168. package/plugins/invoice/templates/_base.css +0 -1
  169. package/plugins/invoice/templates/classic-nologo.html +0 -100
  170. package/plugins/invoice/templates/classic.html +0 -91
  171. package/plugins/invoice/templates/invoice-print.html +0 -237
  172. package/plugins/invoice/templates/minimal.html +0 -99
  173. package/plugins/invoice/templates/modern-nologo.html +0 -114
  174. package/plugins/invoice/templates/modern.html +0 -113
  175. package/plugins/invoice/templates/templates.json +0 -11
  176. package/plugins/notes/admin/templates/notes.html +0 -83
  177. package/plugins/notes/admin/views/notes.js +0 -281
  178. package/plugins/notes/collections/user-notes/schema.json +0 -54
  179. package/plugins/notes/config.js +0 -6
  180. package/plugins/notes/plugin.json +0 -33
  181. package/plugins/surveys/admin/templates/audience.html +0 -47
  182. package/plugins/surveys/admin/templates/results.html +0 -58
  183. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  184. package/plugins/surveys/admin/templates/surveys.html +0 -25
  185. package/plugins/surveys/admin/views/audience.js +0 -301
  186. package/plugins/surveys/admin/views/results.js +0 -172
  187. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  188. package/plugins/surveys/admin/views/surveys.js +0 -161
  189. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  190. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  191. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  192. package/plugins/surveys/collections/surveys/schema.json +0 -23
  193. package/plugins/surveys/config.js +0 -8
  194. package/plugins/surveys/lib/audience.js +0 -37
  195. package/plugins/surveys/lib/campaigns.js +0 -43
  196. package/plugins/surveys/lib/ledger.js +0 -110
  197. package/plugins/surveys/lib/sending.js +0 -106
  198. package/plugins/surveys/lib/stats.js +0 -62
  199. package/plugins/surveys/lib/submit.js +0 -95
  200. package/plugins/surveys/lib/tokens.js +0 -28
  201. package/plugins/surveys/plugin.js +0 -174
  202. package/plugins/surveys/plugin.json +0 -65
  203. package/plugins/surveys/plugin.public.js +0 -149
  204. package/plugins/surveys/public/dist-shared.mjs +0 -1
  205. package/plugins/surveys/public/survey.css +0 -1
  206. package/plugins/surveys/public/survey.mjs +0 -1
  207. package/plugins/surveys/templates/survey-page.html +0 -63
  208. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  209. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  210. package/plugins/theme-switcher/config.js +0 -10
  211. package/plugins/theme-switcher/plugin.js +0 -26
  212. package/plugins/theme-switcher/plugin.json +0 -74
  213. package/plugins/theme-switcher/public/inject-body.html +0 -153
  214. package/plugins/theme-switcher/public/inject-head.html +0 -260
  215. package/plugins/todo/admin/templates/todo.html +0 -158
  216. package/plugins/todo/admin/views/todo.js +0 -343
  217. package/plugins/todo/collections/todos/schema.json +0 -60
  218. package/plugins/todo/config.js +0 -7
  219. package/plugins/todo/plugin.js +0 -102
  220. package/plugins/todo/plugin.json +0 -33
  221. /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
@@ -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
+ }
@@ -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
  }