domma-cms 0.9.10 → 0.12.0

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 (125) hide show
  1. package/CLAUDE.md +248 -159
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/app.js +7 -3
  5. package/admin/js/config/sidebar-config.js +1 -1
  6. package/admin/js/http-interceptor.js +1 -0
  7. package/admin/js/lib/card-builder.js +2 -2
  8. package/admin/js/lib/markdown-toolbar.js +5 -5
  9. package/admin/js/lib/safe-html.js +1 -0
  10. package/admin/js/lib/shortcode-modal.js +1 -0
  11. package/admin/js/templates/layouts.html +5 -4
  12. package/admin/js/templates/notifications.html +14 -0
  13. package/admin/js/templates/plugin-marketplace.html +16 -0
  14. package/admin/js/templates/plugins.html +17 -5
  15. package/admin/js/views/index.js +1 -1
  16. package/admin/js/views/layouts.js +1 -16
  17. package/admin/js/views/notifications.js +1 -0
  18. package/admin/js/views/page-editor.js +37 -33
  19. package/admin/js/views/plugin-marketplace.js +1 -0
  20. package/admin/js/views/plugins.js +16 -16
  21. package/config/navigation.json +5 -72
  22. package/config/plugins.json +10 -14
  23. package/config/presets.json +50 -13
  24. package/config/site.json +11 -63
  25. package/package.json +2 -1
  26. package/plugins/_template/admin/templates/index.html +17 -0
  27. package/plugins/_template/admin/views/index.js +19 -0
  28. package/plugins/_template/config.js +8 -0
  29. package/plugins/_template/plugin.js +23 -0
  30. package/plugins/_template/plugin.json +34 -0
  31. package/plugins/analytics/plugin.json +41 -31
  32. package/plugins/blog/admin/templates/blog.html +22 -0
  33. package/plugins/blog/admin/templates/categories.html +7 -0
  34. package/plugins/blog/admin/templates/comments.html +11 -0
  35. package/plugins/blog/admin/templates/post-editor.html +97 -0
  36. package/plugins/blog/admin/templates/settings.html +11 -0
  37. package/plugins/blog/admin/views/blog.js +183 -0
  38. package/plugins/blog/admin/views/categories.js +235 -0
  39. package/plugins/blog/admin/views/comments.js +187 -0
  40. package/plugins/blog/admin/views/post-editor.js +291 -0
  41. package/plugins/blog/admin/views/settings.js +100 -0
  42. package/plugins/blog/collections/categories/schema.json +12 -0
  43. package/plugins/blog/collections/comments/schema.json +16 -0
  44. package/plugins/blog/collections/posts/schema.json +19 -0
  45. package/plugins/blog/config.js +8 -0
  46. package/plugins/blog/plugin.js +352 -0
  47. package/plugins/blog/plugin.json +96 -0
  48. package/plugins/blog/roles/blog-author.json +10 -0
  49. package/plugins/blog/roles/blog-editor.json +12 -0
  50. package/plugins/blog/templates/author.html +9 -0
  51. package/plugins/blog/templates/category.html +9 -0
  52. package/plugins/blog/templates/index.html +9 -0
  53. package/plugins/blog/templates/post.html +17 -0
  54. package/plugins/blog/templates/tag.html +9 -0
  55. package/plugins/contacts/collections/user-contact-groups/schema.json +1 -1
  56. package/plugins/contacts/collections/user-contacts/schema.json +1 -1
  57. package/plugins/contacts/plugin.js +4 -10
  58. package/plugins/contacts/plugin.json +13 -3
  59. package/plugins/notes/collections/user-notes/schema.json +1 -1
  60. package/plugins/notes/plugin.js +3 -9
  61. package/plugins/notes/plugin.json +13 -3
  62. package/plugins/site-search/plugin.json +5 -2
  63. package/plugins/theme-switcher/plugin.json +1 -1
  64. package/plugins/todo/collections/todos/schema.json +1 -1
  65. package/plugins/todo/plugin.js +3 -9
  66. package/plugins/todo/plugin.json +13 -3
  67. package/public/css/site.css +1 -1
  68. package/public/js/site.js +1 -1
  69. package/scripts/build.js +48 -0
  70. package/scripts/create-plugin.js +113 -0
  71. package/scripts/fresh.js +6 -7
  72. package/scripts/gen-instance-secret.js +46 -0
  73. package/scripts/reset.js +3 -3
  74. package/scripts/setup.js +31 -13
  75. package/server/middleware/auth.js +48 -0
  76. package/server/middleware/managerAuth.js +36 -0
  77. package/server/routes/api/actions.js +1 -1
  78. package/server/routes/api/auth.js +4 -3
  79. package/server/routes/api/layouts.js +173 -49
  80. package/server/routes/api/notifications.js +155 -0
  81. package/server/routes/api/plugin-marketplace.js +75 -0
  82. package/server/routes/api/users.js +1 -1
  83. package/server/routes/api/views.js +1 -1
  84. package/server/routes/public.js +4 -9
  85. package/server/server.js +32 -3
  86. package/server/services/actions.js +1 -1
  87. package/server/services/managerClient.js +182 -0
  88. package/server/services/markdown.js +76 -9
  89. package/server/services/permissionRegistry.js +245 -173
  90. package/server/services/pluginInstaller.js +301 -0
  91. package/server/services/plugins.js +117 -10
  92. package/server/services/presetCollections.js +66 -251
  93. package/server/services/renderer.js +99 -0
  94. package/server/services/roles.js +191 -39
  95. package/server/services/users.js +1 -1
  96. package/server/services/views.js +1 -1
  97. package/server/templates/page.html +2 -2
  98. package/plugins/docs/admin/templates/docs.html +0 -69
  99. package/plugins/docs/admin/views/docs.js +0 -276
  100. package/plugins/docs/config.js +0 -8
  101. package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +0 -11
  102. package/plugins/docs/data/folders.json +0 -9
  103. package/plugins/docs/data/templates.json +0 -1
  104. package/plugins/docs/data/versions/57e003f0-68f2-47dc-9c36-ed4b10ed3deb/1.json +0 -5
  105. package/plugins/docs/plugin.js +0 -375
  106. package/plugins/docs/plugin.json +0 -23
  107. package/plugins/form-builder/data/forms/contacts.json +0 -66
  108. package/plugins/form-builder/data/forms/enquiries.json +0 -103
  109. package/plugins/form-builder/data/forms/feedback.json +0 -131
  110. package/plugins/form-builder/data/forms/notes.json +0 -79
  111. package/plugins/form-builder/data/forms/to-do.json +0 -100
  112. package/plugins/form-builder/data/submissions/contacts.json +0 -1
  113. package/plugins/form-builder/data/submissions/enquiries.json +0 -1
  114. package/plugins/form-builder/data/submissions/feedback.json +0 -1
  115. package/plugins/form-builder/data/submissions/notes.json +0 -1
  116. package/plugins/form-builder/data/submissions/to-do.json +0 -1
  117. package/plugins/garage/admin/templates/garage.html +0 -111
  118. package/plugins/garage/admin/views/garage.js +0 -622
  119. package/plugins/garage/collections/garage-vehicles/schema.json +0 -101
  120. package/plugins/garage/config.js +0 -18
  121. package/plugins/garage/data/vehicles.json +0 -70
  122. package/plugins/garage/plugin.js +0 -398
  123. package/plugins/garage/plugin.json +0 -33
  124. package/scripts/seed.js +0 -1996
  125. package/server/services/userTypes.js +0 -227
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Plugin Installer Service
3
+ * Handles secure installation and uninstallation of CMS plugins from signed tarballs.
4
+ *
5
+ * Security model:
6
+ * - Tarballs must be signed with a key registered in TRUSTED_KEYS
7
+ * - Path traversal is blocked during extraction
8
+ * - Manifest validation is performed post-extract via discoverPlugins()
9
+ *
10
+ * Audit log: content/instances_log.json
11
+ */
12
+
13
+ import crypto from 'crypto';
14
+ import fs from 'fs/promises';
15
+ import path from 'path';
16
+ import zlib from 'zlib';
17
+ import {fileURLToPath} from 'url';
18
+ import {discoverPlugins} from './plugins.js';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Paths
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
25
+ const PLUGINS_DIR = path.join(ROOT, 'plugins');
26
+ const CONFIG_PATH = path.join(ROOT, 'config', 'plugins.json');
27
+ const AUDIT_LOG = path.join(ROOT, 'content', 'instances_log.json');
28
+
29
+ // ---------------------------------------------------------------------------
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
+ // ---------------------------------------------------------------------------
33
+
34
+ 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
43
+ -----END PUBLIC KEY-----`
44
+ };
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Internal helpers
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Append a single record to the audit log, creating the file if absent.
52
+ *
53
+ * @param {object} record
54
+ * @returns {Promise<void>}
55
+ */
56
+ async function writeAuditLog(record) {
57
+ await fs.mkdir(path.dirname(AUDIT_LOG), { recursive: true });
58
+ await fs.appendFile(AUDIT_LOG, JSON.stringify(record) + '\n', 'utf8');
59
+ }
60
+
61
+ /**
62
+ * Read config/plugins.json, returning {} on missing/malformed file.
63
+ *
64
+ * @returns {Promise<object>}
65
+ */
66
+ async function readPluginsConfig() {
67
+ try {
68
+ const raw = await fs.readFile(CONFIG_PATH, 'utf8');
69
+ return JSON.parse(raw);
70
+ } catch {
71
+ return {};
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Write config/plugins.json atomically.
77
+ *
78
+ * @param {object} data
79
+ * @returns {Promise<void>}
80
+ */
81
+ async function writePluginsConfig(data) {
82
+ await fs.writeFile(CONFIG_PATH, JSON.stringify(data, null, 2), 'utf8');
83
+ }
84
+
85
+ /**
86
+ * Parse a raw (decompressed) TAR byte buffer and extract entries to destDir.
87
+ * Only handles POSIX ustar / GNU tar formats sufficient for plugin tarballs.
88
+ * Path traversal outside destDir causes an immediate throw.
89
+ *
90
+ * @param {Buffer} tarBuffer - Decompressed tar data
91
+ * @param {string} slug - Plugin slug (used for guard prefix)
92
+ * @param {string} destDir - Absolute destination directory
93
+ * @returns {Promise<void>}
94
+ */
95
+ async function extractTar(tarBuffer, slug, destDir) {
96
+ const guardPrefix = path.resolve(destDir) + path.sep;
97
+ let offset = 0;
98
+
99
+ let zeroBlockCount = 0;
100
+ while (offset + 512 <= tarBuffer.length) {
101
+ const header = tarBuffer.slice(offset, offset + 512);
102
+
103
+ // All-zero block signals end of archive (two consecutive zero blocks required)
104
+ if (header.every(b => b === 0)) {
105
+ if (++zeroBlockCount >= 2) break;
106
+ offset += 512;
107
+ continue;
108
+ }
109
+ zeroBlockCount = 0;
110
+ offset += 512;
111
+
112
+ // Filename: bytes 0–99, null-terminated
113
+ const nameEnd = header.indexOf(0, 0);
114
+ const name = header.slice(0, nameEnd === -1 ? 100 : Math.min(nameEnd, 100)).toString('utf8');
115
+ if (!name) break;
116
+
117
+ // File size: bytes 124–135, octal ASCII
118
+ const sizeStr = header.slice(124, 136).toString('utf8').replace(/\0/g, '').trim();
119
+ const size = parseInt(sizeStr, 8) || 0;
120
+
121
+ // Type flag: byte 156
122
+ const typeFlag = String.fromCharCode(header[156]);
123
+
124
+ // Strip leading component (e.g. "my-plugin/foo.js" → "foo.js") so that
125
+ // tarballs created with a top-level directory still land correctly.
126
+ const parts = name.split('/');
127
+ const relativePath = parts.length > 1 ? parts.slice(1).join('/') : name;
128
+
129
+ if (relativePath) {
130
+ const target = path.resolve(destDir, relativePath);
131
+
132
+ // Path traversal guard
133
+ if (!target.startsWith(guardPrefix) && target !== path.resolve(destDir)) {
134
+ throw new Error(`Path traversal detected in tar entry: ${name}`);
135
+ }
136
+
137
+ if (typeFlag === '5' || name.endsWith('/')) {
138
+ // Directory entry
139
+ await fs.mkdir(target, { recursive: true });
140
+ } else if (typeFlag === '0' || typeFlag === '' || typeFlag === '\0') {
141
+ // Regular file
142
+ await fs.mkdir(path.dirname(target), { recursive: true });
143
+ const content = tarBuffer.slice(offset, offset + size);
144
+ await fs.writeFile(target, content);
145
+ }
146
+ // Ignore symlinks, hard links, etc. for security
147
+ }
148
+
149
+ // Advance past file data (rounded up to 512-byte block boundary)
150
+ offset += Math.ceil(size / 512) * 512;
151
+ }
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Public API
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /**
159
+ * Install a plugin from a signed .tar.gz buffer.
160
+ *
161
+ * Steps:
162
+ * 1. Verify signature against TRUSTED_KEYS[publicKeyId]
163
+ * 2. Reject if plugins/<slug>/ already exists
164
+ * 3. Extract tarball to plugins/<slug>/ with path traversal guard
165
+ * 4. Validate manifest via discoverPlugins()
166
+ * 5. Add { enabled: false, settings: {} } to config/plugins.json
167
+ * 6. Run onEnable lifecycle hook (warn-only on failure)
168
+ * 7. Append install record to audit log
169
+ *
170
+ * @param {string} slug
171
+ * @param {{ tarball: Buffer, signature: string, publicKeyId: string }} options
172
+ * @returns {Promise<void>}
173
+ * @throws {Error} If signature is invalid, slug is already installed, or manifest validation fails
174
+ */
175
+ export async function installPlugin(slug, { tarball, signature, publicKeyId }) {
176
+ if (!/^[a-z0-9][a-z0-9-_]{0,63}$/.test(slug)) {
177
+ throw new Error(`Invalid plugin slug: ${slug}`);
178
+ }
179
+
180
+ // Step 1 — Verify signature
181
+ const pubKeyPem = TRUSTED_KEYS[publicKeyId];
182
+ if (!pubKeyPem) {
183
+ throw new Error(`Unknown public key: ${publicKeyId}`);
184
+ }
185
+
186
+ if (pubKeyPem.includes('PLACEHOLDER')) {
187
+ throw new Error(`pluginInstaller: TRUSTED_KEYS['${publicKeyId}'] is a placeholder — not usable for verification`);
188
+ }
189
+
190
+ const signatureBuffer = Buffer.from(signature, 'base64');
191
+ const valid = crypto.verify('sha256', tarball, pubKeyPem, signatureBuffer);
192
+ if (!valid) {
193
+ throw new Error('Plugin signature invalid — refusing to install');
194
+ }
195
+
196
+ // Step 2 — Reject if already installed
197
+ const destDir = path.join(PLUGINS_DIR, slug);
198
+ let alreadyExists = false;
199
+ try {
200
+ await fs.access(destDir);
201
+ alreadyExists = true;
202
+ } catch {
203
+ // Not present — proceed
204
+ }
205
+ if (alreadyExists) {
206
+ throw new Error(`Plugin already installed: ${slug}`);
207
+ }
208
+
209
+ // Step 3 — Decompress + extract
210
+ await fs.mkdir(destDir, { recursive: true });
211
+ try {
212
+ const tarBuffer = zlib.gunzipSync(tarball);
213
+ await extractTar(tarBuffer, slug, destDir);
214
+ } catch (err) {
215
+ // Clean up partial extraction before re-throwing
216
+ await fs.rm(destDir, { recursive: true, force: true });
217
+ throw err;
218
+ }
219
+
220
+ // Step 4 — Validate manifest
221
+ const manifests = await discoverPlugins();
222
+ const manifest = manifests.find(m => m.name === slug);
223
+ if (!manifest) {
224
+ await fs.rm(destDir, { recursive: true, force: true });
225
+ throw new Error(`Plugin manifest validation failed after extraction: ${slug}`);
226
+ }
227
+
228
+ // Step 5 — Update config/plugins.json
229
+ const cfg = await readPluginsConfig();
230
+ if (!cfg[slug]) {
231
+ cfg[slug] = { enabled: false, settings: {} };
232
+ await writePluginsConfig(cfg);
233
+ }
234
+
235
+ // Step 6 — Run onEnable lifecycle hook (warn on failure, do not throw)
236
+ try {
237
+ const pluginJsPath = path.join(destDir, 'plugin.js');
238
+ const mod = await import(pluginJsPath);
239
+ if (typeof mod.onEnable === 'function') {
240
+ await mod.onEnable(null);
241
+ }
242
+ } catch (err) {
243
+ console.warn(`[pluginInstaller] onEnable hook for "${slug}" failed: ${err.message}`);
244
+ }
245
+
246
+ // Step 7 — Audit log
247
+ await writeAuditLog({
248
+ action: 'install',
249
+ slug,
250
+ version: manifest.version,
251
+ timestamp: new Date().toISOString()
252
+ });
253
+ }
254
+
255
+ /**
256
+ * Uninstall a plugin by slug.
257
+ *
258
+ * Steps:
259
+ * 1. Run onDisable lifecycle hook (warn-only on failure)
260
+ * 2. Remove plugins/<slug>/ directory
261
+ * 3. Remove entry from config/plugins.json
262
+ * 4. Append uninstall record to audit log
263
+ *
264
+ * @param {string} slug
265
+ * @returns {Promise<void>}
266
+ */
267
+ export async function uninstallPlugin(slug) {
268
+ if (!/^[a-z0-9][a-z0-9-_]{0,63}$/.test(slug)) {
269
+ throw new Error(`Invalid plugin slug: ${slug}`);
270
+ }
271
+
272
+ const destDir = path.join(PLUGINS_DIR, slug);
273
+
274
+ // Step 1 — Run onDisable lifecycle hook (warn on failure, do not throw)
275
+ try {
276
+ const pluginJsPath = path.join(destDir, 'plugin.js');
277
+ const mod = await import(pluginJsPath);
278
+ if (typeof mod.onDisable === 'function') {
279
+ await mod.onDisable(null);
280
+ }
281
+ } catch (err) {
282
+ console.warn(`[pluginInstaller] onDisable hook for "${slug}" failed: ${err.message}`);
283
+ }
284
+
285
+ // Step 2 — Remove plugin directory
286
+ await fs.rm(destDir, { recursive: true, force: true });
287
+
288
+ // Step 3 — Remove from config/plugins.json
289
+ const cfg = await readPluginsConfig();
290
+ if (slug in cfg) {
291
+ delete cfg[slug];
292
+ await writePluginsConfig(cfg);
293
+ }
294
+
295
+ // Step 4 — Audit log
296
+ await writeAuditLog({
297
+ action: 'uninstall',
298
+ slug,
299
+ timestamp: new Date().toISOString()
300
+ });
301
+ }
@@ -14,13 +14,23 @@ import fs from 'fs/promises';
14
14
  import path from 'path';
15
15
  import matter from 'gray-matter';
16
16
  import {getConfig, saveConfig} from '../config.js';
17
- import {authenticate, requireAdmin, requireRole} from '../middleware/auth.js';
17
+ import {authenticate, requireAdmin, requireRole, requireVisibility} from '../middleware/auth.js';
18
18
  import {hooks, registerSanitizeRules, registerShortcode, registerTransform} from './hooks.js';
19
+ import {registerPluginResource, unregisterPluginResourcesByPlugin} from './permissionRegistry.js';
20
+ import {createCollection, getCollection} from './collections.js';
19
21
 
20
22
  const PLUGINS_DIR = path.resolve('plugins');
21
23
 
22
24
  const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'version', 'description', 'author', 'date', 'icon'];
23
25
 
26
+ /**
27
+ * In-memory registry of loaded plugins, populated by registerPlugins().
28
+ * Keyed by plugin name; each entry mirrors the manifest plus runtime metadata.
29
+ *
30
+ * @type {Object.<string, { enabled: boolean, publicEntry: string|null }>}
31
+ */
32
+ const _loadedPlugins = {};
33
+
24
34
  /**
25
35
  * Core plugins — always loaded regardless of plugins.json enabled state.
26
36
  * These are considered first-class CMS features, not optional add-ons.
@@ -43,7 +53,7 @@ export async function discoverPlugins() {
43
53
  }
44
54
 
45
55
  const plugins = [];
46
- for (const entry of entries.filter(e => e.isDirectory())) {
56
+ for (const entry of entries.filter(e => e.isDirectory() && !e.name.startsWith('_'))) {
47
57
  const dir = entry.name;
48
58
  const pluginDir = path.join(PLUGINS_DIR, dir);
49
59
  const manifestPath = path.join(pluginDir, 'plugin.json');
@@ -163,11 +173,26 @@ export async function registerPlugins(fastify) {
163
173
  const prefix = `/api/plugins/${manifest.name}`;
164
174
  await fastify.register(plugin, {
165
175
  prefix,
166
- auth: {authenticate, requireRole, requireAdmin},
176
+ auth: {authenticate, requireRole, requireAdmin, requireVisibility},
167
177
  hooks: {registerShortcode, registerSanitizeRules, registerTransform, on: hooks.on.bind(hooks)},
168
- settings
178
+ settings,
179
+ config: {}
169
180
  });
170
181
  loaded.push(manifest.name);
182
+
183
+ // Ensure plugin collections exist (idempotent — skips if already present)
184
+ await setupPlugin(manifest.name, {collections: {getCollection, createCollection}})
185
+ .catch(err => fastify.log.warn(`[plugins] Collection setup for "${manifest.name}" failed: ${err.message}`));
186
+
187
+ // Detect optional plugin.public.js for root-level route registration
188
+ _loadedPlugins[manifest.name] = { enabled: true, publicEntry: null };
189
+ const publicEntryPath = path.join(PLUGINS_DIR, manifest.name, 'plugin.public.js');
190
+ try {
191
+ await fs.access(publicEntryPath);
192
+ _loadedPlugins[manifest.name].publicEntry = publicEntryPath;
193
+ } catch {
194
+ // no public entry — that's fine
195
+ }
171
196
  } catch (err) {
172
197
  fastify.log.error(`Plugin "${manifest.name}" server failed to load: ${err.message}`);
173
198
  }
@@ -180,6 +205,16 @@ export async function registerPlugins(fastify) {
180
205
  }
181
206
  }
182
207
 
208
+ /**
209
+ * Return the in-memory registry of plugins loaded by registerPlugins().
210
+ * Each entry includes `enabled` and `publicEntry` (path or null).
211
+ *
212
+ * @returns {Object.<string, { enabled: boolean, publicEntry: string|null }>}
213
+ */
214
+ export function getLoadedPlugins() {
215
+ return _loadedPlugins;
216
+ }
217
+
183
218
  /**
184
219
  * Return merged settings for a plugin: config.js defaults + user overrides from config/plugins.json.
185
220
  *
@@ -263,7 +298,7 @@ async function walkMdFiles(dir) {
263
298
  * @returns {Promise<{ collections: string[], forms: string[], pages: string[] }>}
264
299
  */
265
300
  export async function setupPlugin(pluginName, services) {
266
- const result = {collections: [], forms: [], pages: []};
301
+ const result = {collections: [], forms: [], pages: [], roles: []};
267
302
  const pluginDir = path.join(PLUGINS_DIR, pluginName);
268
303
 
269
304
  // Collections
@@ -341,6 +376,37 @@ export async function setupPlugin(pluginName, services) {
341
376
  }
342
377
  }
343
378
 
379
+ // Roles — read plugins/<name>/roles/*.json, register resources + persist roles
380
+ if (services.roles) {
381
+ const rolesDir = path.join(pluginDir, 'roles');
382
+ let roleFiles;
383
+ try {
384
+ roleFiles = await fs.readdir(rolesDir);
385
+ } catch {
386
+ roleFiles = [];
387
+ }
388
+ for (const file of roleFiles.filter(f => f.endsWith('.json'))) {
389
+ try {
390
+ const raw = await fs.readFile(path.join(rolesDir, file), 'utf8');
391
+ const def = JSON.parse(raw);
392
+
393
+ // Register any declared resources into the permission overlay
394
+ if (Array.isArray(def.resources)) {
395
+ for (const resource of def.resources) {
396
+ registerPluginResource(resource, pluginName);
397
+ }
398
+ }
399
+
400
+ // Persist the role (idempotent)
401
+ const {resources: _resources, ...roleData} = def;
402
+ await services.roles.createRole({...roleData, plugin: pluginName});
403
+ result.roles.push(def.name);
404
+ } catch {
405
+ // Skip missing or invalid role files
406
+ }
407
+ }
408
+ }
409
+
344
410
  return result;
345
411
  }
346
412
 
@@ -354,7 +420,7 @@ export async function setupPlugin(pluginName, services) {
354
420
  * @returns {Promise<{ pages: string[], forms: string[] }>}
355
421
  */
356
422
  export async function teardownPlugin(pluginName, services, fastify) {
357
- const result = {pages: [], forms: []};
423
+ const result = {pages: [], forms: [], roles: []};
358
424
 
359
425
  // Pages
360
426
  if (services.content) {
@@ -390,6 +456,22 @@ export async function teardownPlugin(pluginName, services, fastify) {
390
456
  }
391
457
  }
392
458
 
459
+ // Roles — remove plugin-contributed roles and unregister their resources
460
+ if (services.roles) {
461
+ try {
462
+ await services.roles.removeRolesByPlugin(pluginName);
463
+ unregisterPluginResourcesByPlugin(pluginName);
464
+ result.roles.push(pluginName);
465
+ } catch (err) {
466
+ fastify.log.warn(`[plugins] Could not remove roles for "${pluginName}": ${err.message}`);
467
+ }
468
+ }
469
+
470
+ if (_loadedPlugins[pluginName]) {
471
+ _loadedPlugins[pluginName].enabled = false;
472
+ _loadedPlugins[pluginName].publicEntry = null;
473
+ }
474
+
393
475
  return result;
394
476
  }
395
477
 
@@ -451,11 +533,13 @@ export async function runLifecycleHook(name, hook, fastify) {
451
533
  if (setup.collections.length) fastify.log.info(`[plugins] Created collections for "${name}": ${setup.collections.join(', ')}`);
452
534
  if (setup.forms.length) fastify.log.info(`[plugins] Created forms for "${name}": ${setup.forms.join(', ')}`);
453
535
  if (setup.pages.length) fastify.log.info(`[plugins] Created pages for "${name}": ${setup.pages.join(', ')}`);
536
+ if (setup.roles.length) fastify.log.info(`[plugins] Registered roles for "${name}": ${setup.roles.join(', ')}`);
454
537
  }
455
538
  if (hook === 'onDisable') {
456
539
  const torn = await teardownPlugin(name, services, fastify);
457
540
  if (torn.pages.length) fastify.log.info(`[plugins] Removed pages for "${name}": ${torn.pages.join(', ')}`);
458
541
  if (torn.forms.length) fastify.log.info(`[plugins] Removed forms for "${name}": ${torn.forms.join(', ')}`);
542
+ if (torn.roles.length) fastify.log.info(`[plugins] Removed roles for "${name}"`);
459
543
  }
460
544
 
461
545
  if (typeof mod[hook] === 'function') {
@@ -467,10 +551,12 @@ export async function runLifecycleHook(name, hook, fastify) {
467
551
  }
468
552
 
469
553
  /**
470
- * Return merged sidebar items, routes, and views from all enabled plugins.
554
+ * Return merged sidebar items, routes, views, and CSS links from all enabled plugins.
555
+ * View entry paths are automatically stamped with a mtime-based version so manual
556
+ * `?v=N` bumps in plugin.json are never needed again.
471
557
  * Used by the frontend to dynamically extend the admin panel.
472
558
  *
473
- * @returns {Promise<{ sidebar: object[], routes: object[], views: object }>}
559
+ * @returns {Promise<{ sidebar: object[], routes: object[], views: object, css: object[] }>}
474
560
  */
475
561
  export async function getAdminPluginConfig() {
476
562
  const manifests = await discoverPlugins();
@@ -479,6 +565,7 @@ export async function getAdminPluginConfig() {
479
565
  const sidebar = [];
480
566
  const routes = [];
481
567
  const views = {};
568
+ const css = [];
482
569
 
483
570
  for (const manifest of manifests) {
484
571
  const state = states[manifest.name] || {};
@@ -486,8 +573,28 @@ export async function getAdminPluginConfig() {
486
573
 
487
574
  if (manifest.admin.sidebar) sidebar.push(...manifest.admin.sidebar);
488
575
  if (manifest.admin.routes) routes.push(...manifest.admin.routes);
489
- if (manifest.admin.views) Object.assign(views, manifest.admin.views);
576
+
577
+ // Auto-stamp view entries with mtime-based version (replaces manual ?v=N)
578
+ for (const [viewName, viewDef] of Object.entries(manifest.admin.views || {})) {
579
+ const entryBase = viewDef.entry.split('?')[0];
580
+ let stamped = viewDef.entry;
581
+ try {
582
+ const filePath = path.join(PLUGINS_DIR, entryBase);
583
+ const s = await fs.stat(filePath);
584
+ stamped = `${entryBase}?v=${Math.floor(s.mtimeMs)}`;
585
+ } catch { /* keep original entry as-is */ }
586
+ views[viewName] = { ...viewDef, entry: stamped };
587
+ }
588
+
589
+ // Collect admin CSS links declared in plugin.json admin.css[]
590
+ for (let i = 0; i < (manifest.admin.css || []).length; i++) {
591
+ const cssPath = manifest.admin.css[i];
592
+ css.push({
593
+ id: `plugin-${manifest.name}-css${i > 0 ? `-${i}` : ''}`,
594
+ href: `/plugins/${manifest.name}/${cssPath}`
595
+ });
596
+ }
490
597
  }
491
598
 
492
- return { sidebar, routes, views };
599
+ return { sidebar, routes, views, css };
493
600
  }