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
@@ -1,93 +1,55 @@
1
1
  /**
2
2
  * Client for outbound calls from domma-cms → domma-cms-manager.
3
- * Reads MANAGER_URL and INSTANCE_SECRET from env at call time.
4
- * All functions return null/[] gracefully if MANAGER_URL is unset.
5
- */
6
-
7
- import {createHmac} from 'node:crypto';
8
- import {readFile} from 'node:fs/promises';
9
- import {fileURLToPath} from 'node:url';
10
- import {dirname, join} from 'node:path';
11
-
12
- const __dirname = dirname(fileURLToPath(import.meta.url));
13
-
14
- /**
15
- * Read the CMS version from the root package.json.
16
- * @returns {Promise<string>} The version string, or '0.0.0' on error.
3
+ *
4
+ * A MANAGED site is one the manager forked, and the manager tells it so in its
5
+ * environment: `MANAGER_URL` (where), `SITE_SLUG` (who) and `FLEET_TOKEN` (the
6
+ * proof, minted per fork and never written down). An unmanaged site has none of
7
+ * these, every call here returns null/[], and the Marketplace screen points at
8
+ * Install from File instead - which is the whole of that customer's procedure.
9
+ *
10
+ * There used to be a `registerInstance()` handshake: HMAC a fingerprint with a
11
+ * shared INSTANCE_SECRET, get a licence token back. It never had a caller, and
12
+ * it was solving a problem this fleet does not have - the manager starts every
13
+ * child itself, so it already knows which site is on the other end. Replacing
14
+ * it with a fork-time token removed a shared secret, a distribution problem and
15
+ * a round trip.
16
+ *
17
+ * Nothing here throws. A manager that is down, slow or mid-restart must leave
18
+ * the site serving pages, so every failure is a warning and an empty answer.
17
19
  */
18
- async function getCmsVersion() {
19
- try {
20
- const pkgPath = join(__dirname, '../../package.json');
21
- const raw = await readFile(pkgPath, 'utf-8');
22
- return JSON.parse(raw).version ?? '0.0.0';
23
- } catch {
24
- return '0.0.0';
25
- }
26
- }
27
20
 
28
21
  /**
29
- * Register this CMS instance with domma-cms-manager.
22
+ * True when this site was started by a manager and can talk to it.
30
23
  *
31
- * @param {string} fingerprint - Unique instance fingerprint.
32
- * @returns {Promise<{licenseToken: string, instanceId: string}|null>}
24
+ * @returns {boolean}
33
25
  */
34
- export async function registerInstance(fingerprint) {
35
- const MANAGER_URL = process.env.MANAGER_URL;
36
- const INSTANCE_SECRET = process.env.INSTANCE_SECRET;
37
-
38
- if (!MANAGER_URL) return null;
39
- if (!INSTANCE_SECRET) {
40
- console.warn('[managerClient] INSTANCE_SECRET not set - skipping registerInstance');
41
- return null;
42
- }
43
-
44
- try {
45
- const cmsVersion = await getCmsVersion();
46
- const hostname = process.env.HOSTNAME ?? 'unknown';
47
-
48
- const token = createHmac('sha256', INSTANCE_SECRET)
49
- .update(fingerprint)
50
- .digest('hex');
51
-
52
- const res = await fetch(`${MANAGER_URL}/api/instances/register`, {
53
- signal: AbortSignal.timeout(10_000),
54
- method: 'POST',
55
- headers: {
56
- 'Content-Type': 'application/json',
57
- 'X-Instance-Token': token,
58
- },
59
- body: JSON.stringify({ fingerprint, cmsVersion, hostname }),
60
- });
26
+ export function isManaged() {
27
+ return Boolean(process.env.MANAGER_URL && process.env.FLEET_TOKEN);
28
+ }
61
29
 
62
- if (!res.ok) {
63
- console.warn(`[managerClient] registerInstance failed: ${res.status} ${res.statusText}`);
64
- return null;
65
- }
30
+ /** The slug this site is known by in its fleet, or null when unmanaged. */
31
+ export function siteSlug() {
32
+ return process.env.SITE_SLUG || null;
33
+ }
66
34
 
67
- return await res.json();
68
- } catch (err) {
69
- console.warn('[managerClient] registerInstance error:', err.message);
70
- return null;
71
- }
35
+ /** Bearer the fork-time token. Read per call: a restart mints a new one. */
36
+ function fleetHeaders() {
37
+ return {Authorization: `Bearer ${process.env.FLEET_TOKEN}`};
72
38
  }
73
39
 
74
40
  /**
75
41
  * Fetch the plugin catalogue from domma-cms-manager.
76
42
  *
77
- * @param {string} licenseToken - License token obtained from registerInstance.
78
- * @returns {Promise<Array<{slug: string, version: string, displayName: string, description: string, price: number, entitled: boolean}>>}
43
+ * @returns {Promise<Array<{slug: string, version: string, displayName: string,
44
+ * description: string, price: number|null, entitled: boolean, licenceStatus: string|null}>>}
79
45
  */
80
- export async function fetchCatalogue(licenseToken) {
81
- const MANAGER_URL = process.env.MANAGER_URL;
82
-
83
- if (!MANAGER_URL) return [];
46
+ export async function fetchCatalogue() {
47
+ if (!isManaged()) return [];
84
48
 
85
49
  try {
86
- const res = await fetch(`${MANAGER_URL}/api/plugins/catalogue`, {
50
+ const res = await fetch(`${process.env.MANAGER_URL}/api/plugins/catalogue`, {
87
51
  signal: AbortSignal.timeout(10_000),
88
- headers: {
89
- Authorization: `Bearer ${licenseToken}`,
90
- },
52
+ headers: fleetHeaders(),
91
53
  });
92
54
 
93
55
  if (!res.ok) {
@@ -103,80 +65,54 @@ export async function fetchCatalogue(licenseToken) {
103
65
  }
104
66
 
105
67
  /**
106
- * Fetch a plugin bundle (tarball + signature) from domma-cms-manager.
68
+ * Fetch a plugin bundle from domma-cms-manager.
69
+ *
70
+ * Returns the triple `installPlugin()` takes, plus the entitlement the manager
71
+ * minted for THIS site. The entitlement travels with the bytes it licenses
72
+ * rather than arriving separately, because a paid plugin installed without one
73
+ * is a plugin that will not load - a failure that looks like a broken install
74
+ * rather than a licensing answer.
107
75
  *
108
- * @param {string} slug - Plugin slug.
109
- * @param {string} version - Plugin version.
110
- * @param {string} licenseToken - License token obtained from registerInstance.
111
- * @returns {Promise<{tarball: Buffer, signature: string, publicKeyId: string}|null>}
76
+ * @param {string} slug
77
+ * @param {string} version
78
+ * @returns {Promise<{tarball: Buffer, signature: string, publicKeyId: string,
79
+ * entitlement: object|null, version: string}|null>} null when unmanaged or unreachable
80
+ * @throws {Error} when the manager refuses - an unlicensed plugin is an answer
81
+ * worth showing, not a silent empty result
112
82
  */
113
- export async function fetchPluginBundle(slug, version, licenseToken) {
114
- const MANAGER_URL = process.env.MANAGER_URL;
115
-
116
- if (!MANAGER_URL) return null;
83
+ export async function fetchPluginBundle(slug, version) {
84
+ if (!isManaged()) return null;
117
85
 
86
+ let res;
118
87
  try {
119
- const res = await fetch(`${MANAGER_URL}/api/plugins/install/${slug}/${version}`, {
120
- signal: AbortSignal.timeout(10_000),
121
- headers: {
122
- Authorization: `Bearer ${licenseToken}`,
123
- },
88
+ res = await fetch(`${process.env.MANAGER_URL}/api/plugins/install/${slug}/${version}`, {
89
+ signal: AbortSignal.timeout(30_000),
90
+ headers: fleetHeaders(),
124
91
  });
125
-
126
- if (!res.ok) {
127
- console.warn(`[managerClient] fetchPluginBundle failed: ${res.status} ${res.statusText}`);
128
- return null;
129
- }
130
-
131
- const json = await res.json();
132
-
133
- if (typeof json.tarball !== 'string' || json.tarball.length > 100 * 1024 * 1024) {
134
- console.warn('[managerClient] fetchPluginBundle: tarball too large or invalid, refusing decode');
135
- return null;
136
- }
137
-
138
- return {
139
- tarball: Buffer.from(json.tarball, 'base64'),
140
- signature: json.signature,
141
- publicKeyId: json.publicKeyId,
142
- };
143
92
  } catch (err) {
144
93
  console.warn('[managerClient] fetchPluginBundle error:', err.message);
145
94
  return null;
146
95
  }
147
- }
148
96
 
149
- /**
150
- * Send a heartbeat to domma-cms-manager with the list of installed plugins.
151
- *
152
- * @param {string} licenseToken - License token obtained from registerInstance.
153
- * @param {string[]} installedSlugs - Array of installed plugin slugs.
154
- * @returns {Promise<boolean>} True on success, false on error.
155
- */
156
- export async function heartbeat(licenseToken, installedSlugs) {
157
- const MANAGER_URL = process.env.MANAGER_URL;
158
-
159
- if (!MANAGER_URL) return false;
160
-
161
- try {
162
- const res = await fetch(`${MANAGER_URL}/api/instances/heartbeat`, {
163
- signal: AbortSignal.timeout(10_000),
164
- method: 'POST',
165
- headers: {
166
- 'Content-Type': 'application/json',
167
- Authorization: `Bearer ${licenseToken}`,
168
- },
169
- body: JSON.stringify({ installedSlugs, versions: {} }),
170
- });
171
-
172
- if (!res.ok) {
173
- console.warn(`[managerClient] heartbeat failed: ${res.status} ${res.statusText}`);
174
- return false;
175
- }
97
+ if (!res.ok) {
98
+ // 403 is "you have no licence for this", 409 "your catalogue is stale".
99
+ // Both are things the admin can act on, so they are raised rather than
100
+ // logged and swallowed the way an unreachable manager is.
101
+ const body = await res.json().catch(() => ({}));
102
+ throw new Error(body.error || `The manager refused that download (${res.status}).`);
103
+ }
176
104
 
177
- return true;
178
- } catch (err) {
179
- console.warn('[managerClient] heartbeat error:', err.message);
180
- return false;
105
+ const json = await res.json();
106
+ if (typeof json.tarball !== 'string' || json.tarball.length > 100 * 1024 * 1024) {
107
+ console.warn('[managerClient] fetchPluginBundle: tarball too large or invalid, refusing decode');
108
+ return null;
181
109
  }
110
+
111
+ return {
112
+ tarball: Buffer.from(json.tarball, 'base64'),
113
+ signature: json.signature,
114
+ publicKeyId: json.publicKeyId,
115
+ entitlement: json.entitlement ?? null,
116
+ version: json.version ?? version,
117
+ };
182
118
  }
@@ -28,6 +28,7 @@ const BUILTIN_SHORTCODES = new Set([
28
28
  'banner', 'slideover', 'counter', 'celebrate', 'firework', 'fireworks', 'scribe',
29
29
  'reveal', 'breathe', 'pulse', 'shake', 'scramble', 'ripple', 'twinkle',
30
30
  'ticker-tape', 'butterflies', 'strobe', 'animate', 'ambient', 'list-group', 'menu',
31
+ 'demo',
31
32
  ]);
32
33
 
33
34
  // Configure marked for safe output
@@ -3423,6 +3424,51 @@ function processIconBlocks(markdown) {
3423
3424
  ));
3424
3425
  }
3425
3426
 
3427
+ // ─── [demo] - embed a local demo page ──────────────────────────────────────
3428
+
3429
+ /** Where [demo src="..."] looks for its pages, unless the tag says otherwise. */
3430
+ const DEMO_BASE_PATH = '/public/demos';
3431
+ const DEMO_DEFAULT_HEIGHT = '500px';
3432
+
3433
+ /**
3434
+ * [demo src="elements/accordion" /]
3435
+ * [demo src="effects/ambient" height="600px" title="Ambient Effect" base="/public/x" /]
3436
+ *
3437
+ * Was the `demo-viewer` plugin until 2026-09-22. It is a shortcode and nothing
3438
+ * else - no admin screen, no routes, no data - so it belongs here rather than
3439
+ * behind an install.
3440
+ *
3441
+ * The src is a SAME-ORIGIN path, which is why `allowIframeRelativeUrls` matters
3442
+ * in the sanitizer below. `base` exists because the plugin had a configurable
3443
+ * basePath; an attribute serves the one site that set it without inventing a
3444
+ * config key nobody else would ever touch.
3445
+ *
3446
+ * @param {string} markdown
3447
+ * @returns {string}
3448
+ */
3449
+ function processDemoBlocks(markdown) {
3450
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
3451
+ return restore(scrubbed.replace(
3452
+ /\[demo([^\]]*?)\/\]/gi,
3453
+ (_, attrStr) => {
3454
+ const attrs = parseShortcodeAttrs(attrStr);
3455
+ const src = typeof attrs.src === 'string' ? attrs.src.trim() : '';
3456
+ if (!src) return '';
3457
+ // A src is a path fragment under base, never a URL of its own: anything
3458
+ // carrying a scheme or a protocol-relative prefix would escape the site.
3459
+ if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith('//')) return '';
3460
+ const base = (typeof attrs.base === 'string' && attrs.base.startsWith('/'))
3461
+ ? attrs.base.replace(/\/+$/, '')
3462
+ : DEMO_BASE_PATH;
3463
+ const height = attrs.height || DEMO_DEFAULT_HEIGHT;
3464
+ const title = attrs.title || '';
3465
+ const iframeSrc = `${base}/${src.replace(/^\/+/, '')}.html`;
3466
+ const titleBar = title ? `<div class="demo-viewer-title">${escapeAttr(title)}</div>` : '';
3467
+ return `<div class="demo-viewer">${titleBar}<iframe src="${escapeAttr(iframeSrc)}" style="width:100%;height:${escapeAttr(height)};border:none;border-radius:var(--dm-radius, 8px);background:var(--dm-surface, #1a1a2e);" loading="lazy" allowfullscreen></iframe></div>`;
3468
+ }
3469
+ ));
3470
+ }
3471
+
3426
3472
  // ─── [embed] - video / iframe embeds ───────────────────────────────────────
3427
3473
 
3428
3474
  /**
@@ -4135,7 +4181,8 @@ export async function parseMarkdown(raw, opts = {}) {
4135
4181
  const withSpacer = processSpacerBlocks(withListGroup);
4136
4182
  const withCenter = processCenterBlocks(withSpacer);
4137
4183
  const withIcon = processIconBlocks(withCenter);
4138
- const withEmbed = processEmbedBlocks(withIcon);
4184
+ const withDemo = processDemoBlocks(withIcon);
4185
+ const withEmbed = processEmbedBlocks(withDemo);
4139
4186
  const withForm = await processFormBlocks(withEmbed, tagSet);
4140
4187
  const withHero = processHeroBlocks(withForm);
4141
4188
  const withTable = processTableBlocks(withHero);
@@ -4163,6 +4210,12 @@ export async function parseMarkdown(raw, opts = {}) {
4163
4210
  'player.vimeo.com',
4164
4211
  ...getEmbedAllowedHosts(),
4165
4212
  ],
4213
+ // sanitize-html drops a RELATIVE iframe src whenever allowedIframeHostnames
4214
+ // is set, because it cannot match a hostname it has not been given. That
4215
+ // silently emptied every [demo] iframe. A relative src is same-origin by
4216
+ // definition, so the hostname gate has nothing to say about it: the gate
4217
+ // exists to stop embedding somebody else's site, not your own page.
4218
+ allowIframeRelativeUrls: true,
4166
4219
  allowedTags: sanitizeHtml.defaults.allowedTags.concat([
4167
4220
  'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
4168
4221
  'form', 'input', 'textarea', 'select', 'option', 'optgroup',
@@ -202,6 +202,56 @@ export const REGISTRY = [
202
202
  {key: 'delete', label: 'Delete', description: 'Remove settings'}
203
203
  ]
204
204
  },
205
+ {
206
+ key: 'contacts',
207
+ label: 'Contacts',
208
+ description: 'Address book with groups, favourites and import/export. Each user sees only their own.',
209
+ icon: 'users',
210
+ group: 'Tools',
211
+ actions: [
212
+ {key: 'read', label: 'View', description: 'View own contacts'},
213
+ {key: 'create', label: 'Create', description: 'Add contacts and groups'},
214
+ {key: 'update', label: 'Edit', description: 'Edit own contacts'},
215
+ {key: 'delete', label: 'Delete', description: 'Delete own contacts'}
216
+ ]
217
+ },
218
+ {
219
+ key: 'analytics',
220
+ label: 'Analytics',
221
+ description: 'Page-view analytics: trends, top pages, journeys and export.',
222
+ icon: 'chart-bar',
223
+ group: 'Tools',
224
+ actions: [
225
+ {key: 'read', label: 'View', description: 'View analytics'},
226
+ {key: 'delete', label: 'Reset', description: 'Reset all counters'}
227
+ ]
228
+ },
229
+ {
230
+ key: 'notes',
231
+ label: 'Notes',
232
+ description: 'Personal notes. Each user sees only their own.',
233
+ icon: 'edit-3',
234
+ group: 'Tools',
235
+ actions: [
236
+ {key: 'read', label: 'View', description: 'View own notes'},
237
+ {key: 'create', label: 'Create', description: 'Write a note'},
238
+ {key: 'update', label: 'Edit', description: 'Edit own notes'},
239
+ {key: 'delete', label: 'Delete', description: 'Delete own notes'}
240
+ ]
241
+ },
242
+ {
243
+ key: 'todo',
244
+ label: 'Todo',
245
+ description: 'Personal task list. Each user sees only their own.',
246
+ icon: 'check-square',
247
+ group: 'Tools',
248
+ actions: [
249
+ {key: 'read', label: 'View', description: 'View own tasks'},
250
+ {key: 'create', label: 'Create', description: 'Add a task'},
251
+ {key: 'update', label: 'Edit', description: 'Edit own tasks'},
252
+ {key: 'delete', label: 'Delete', description: 'Delete own tasks'}
253
+ ]
254
+ },
205
255
  {
206
256
  key: 'plugins',
207
257
  label: 'Plugins',
@@ -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
+ }