domma-cms 0.70.1 → 0.71.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.
@@ -1,6 +1,6 @@
1
1
  import {test} from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import {addNavLink, blogItem, hasLink, removeNavLink, withoutLink} from '../lib/nav-link.js';
3
+ import {addNavLink, blogItem, hasLink, moveNavLink, removeNavLink, withoutLink} from '../lib/nav-link.js';
4
4
 
5
5
  function fakeMenus(items, locations = {navbar: 'main'}) {
6
6
  const store = {main: {slug: 'main', name: 'Main', items}};
@@ -55,3 +55,12 @@ test('no navbar menu mapped: nothing happens', async () => {
55
55
  assert.equal(await addNavLink('/blog', menus), null);
56
56
  assert.deepEqual(withoutLink([{text: 'A', url: '/a'}], '/blog'), [{text: 'A', url: '/a'}]);
57
57
  });
58
+
59
+ test('a new base path moves the link enabling added, and nothing else', async () => {
60
+ const menus = fakeMenus([{text: 'Home', url: '/'}, {text: 'Blog', url: '/blog'}, {text: 'More', url: '#', items: [{text: 'Old', url: '/blog'}]}]);
61
+ assert.equal(await moveNavLink({menu: 'main', url: '/blog'}, '/news/', menus), true);
62
+ assert.deepEqual(menus.store.main.items.map(i => i.url), ['/', '/news', '#']);
63
+ assert.equal(menus.store.main.items[2].items[0].url, '/blog', 'a nested copy is the site\'s own');
64
+ assert.equal(await moveNavLink({menu: 'main', url: '/gone'}, '/x', menus), false);
65
+ assert.equal(await moveNavLink(null, '/x', menus), false);
66
+ });
@@ -69,3 +69,24 @@ test('a post keeps only the fields it has - no authorship, no sample flag, a val
69
69
  assert.equal(postFields({template: '../../etc/passwd'}, {role: 'admin'}).template, '');
70
70
  assert.equal(postFields({template: ''}, {role: 'admin'}).template, '', 'empty is "use the site default"');
71
71
  });
72
+
73
+ test('/blog/ goes to the index, not the post route with an empty slug', async () => {
74
+ const app = await buildPublic();
75
+ const res = await app.inject({method: 'GET', url: '/blog/?display=list'});
76
+ assert.equal(res.statusCode, 301);
77
+ assert.equal(res.headers.location, '/blog?display=list');
78
+ });
79
+
80
+ test('the pages live at the base path in the settings, not a fixed /blog', async () => {
81
+ const app = Fastify();
82
+ await app.register(blogPublicPlugin, {settings: {basePath: '/news'}, config: {}});
83
+ app.get('/*', async () => 'site page');
84
+ const index = await app.inject({method: 'GET', url: '/news'});
85
+ assert.equal(index.statusCode, 200);
86
+ assert.match(index.body, /data-blog-archive/);
87
+ assert.equal((await app.inject({method: 'GET', url: '/news/'})).headers.location, '/news');
88
+ assert.equal((await app.inject({method: 'GET', url: '/news/feed.xml'})).statusCode, 200);
89
+ const other = await app.inject({method: 'GET', url: '/blog'});
90
+ assert.equal(other.body, 'site page', 'the old path is the site\'s again');
91
+ assert.equal((await app.inject({method: 'POST', url: '/news'})).statusCode, 404, 'GET only');
92
+ });
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
- "source": "dcms-marketplace@cb07e46",
3
+ "source": "dcms-marketplace@d9bfe49",
4
4
  "plugins": {
5
5
  "blog": {
6
- "version": "1.6.0",
6
+ "version": "1.6.3",
7
7
  "files": {
8
- "CLAUDE.md": "155f6e6e09531b8ab87252f942c1c75121a6c10abdebc4316fa7da44edb42642",
8
+ "CLAUDE.md": "63b11d562db0e0003b929320f55f2a6dedf6ba98a20ed6f5c768fa3177286de9",
9
9
  "admin/css/index.css": "6142a6b756e1218157842528830b1e84f592f0b84b7d5ce6449db421b6f4b039",
10
10
  "admin/templates/blog.html": "a6054c1febe764bb1edf5c9e466d44a9080d9c83ba5486437b7a8244acf5ba54",
11
11
  "admin/templates/categories.html": "ab7d6fc2fedd48876c585bc216a1532b3bdd78c946dee976c1171bfe4664feb6",
@@ -15,7 +15,7 @@
15
15
  "admin/views/blog.js": "3fc84f7ea9fce352f150771b4d1f775965592a8690ad7fd1110a3a4e72c49c74",
16
16
  "admin/views/categories.js": "75140feb2b382ba0ab4a52c30007845bf576883c99577fc0965783adb8808f6b",
17
17
  "admin/views/comments.js": "7c93f964560b5b02df270bd17526b8f749cc7c8871a9e863bb84e9cbfbc4da0e",
18
- "admin/views/kit.js": "e7a787836090f622d7108f2e31a041bfedd0aa6ffb9c9d0b341daa2640e13e2e",
18
+ "admin/views/kit.js": "91292d63a96c1d1e9fdc0c8f931d0f31e07fa37d06f06e0fc45b308863e126aa",
19
19
  "admin/views/post-editor.js": "6bb0867d3b57d817497115e5c56479332f2a5324ab99aab2fdabc714c888c543",
20
20
  "admin/views/settings-cog.js": "307efc93835863dd8511f8fb17a224cf45a77532c90b854a86d2cd1d4e5f9f18",
21
21
  "admin/views/settings.js": "2b7f775e37e034dee4ae3dfadea5e446c2e526bed8a066ef3ca2e777146ada23",
@@ -41,14 +41,14 @@
41
41
  "collections/comments/schema.json": "29cbd289cbfcfc4158c9357a218db4ce06eddc3370bfbf9afb7d7f52065a849e",
42
42
  "collections/posts/schema.json": "19a5a770d3eefea73f4117454bd1312a7a7e3d727cfcdfde141139fbc81cabd7",
43
43
  "config.js": "ee67396262762f5c31269d258affd75f7392a3e364f72f7792c2f1cdafd94bba",
44
- "lib/layouts.js": "5b3c22cfdd537777418d3d22ce86ba522576c0101e7d51ea0db9b2667caf1f92",
45
- "lib/nav-link.js": "ab68e4f5d86665834d95f845bb9755ee3a7019089be3452571054f4a49befbbb",
46
- "lib/page.js": "1fb9a01c3080e8ecf65c19b1273b23d550a131fe30079a493a9f5b1c90b720ae",
44
+ "lib/layouts.js": "bbb61ac7ec2e0cecd3d59700e9f3bd1064cd53b2e9f639d28b45ffc2ad80ab27",
45
+ "lib/nav-link.js": "6a8f9368bb8aee1c21e90fb335a691859cb9a9bc250c2b9167a33ca9189fa2ff",
46
+ "lib/page.js": "464face32871185368de096ed9a6aa635523bf319af6459308cfceb77d66f82c",
47
47
  "lib/render.js": "0e97c6a950bc962c8d8ea4b2f21d06cb73fbcdc738d8942d6935d01309a29b1d",
48
48
  "lib/samples.js": "e8cdb77ce22e43937707f6854d3a5cb722437dc584e22ac7b24480bef686cf85",
49
- "plugin.js": "9e49e3b1d4844cf5ec33abf350ca9cd38e8efa4b93be2c628842a923033350cf",
50
- "plugin.json": "20603ae07a6dade8f6fef14539e3b629c7c0c07dea8322834a4827f86dcf204e",
51
- "plugin.public.js": "c338fff588ab7bd02baf1bc6d5a729a0f0d2ecfa90c087cadfd792ba4d3fd9c2",
49
+ "plugin.js": "ccfd8735838bb6c8c5d45c3c3589d6adcf114a89b560f66e93a1077e9bea3bfb",
50
+ "plugin.json": "5654242b0fd5d4f6039a87dc429bd69a24355a59c3409be10998410fbe6d019b",
51
+ "plugin.public.js": "8efc1ff3a2ead68d36fbd545f8ce590f600b293896ba6f4947418b1efcdf2947",
52
52
  "public/blog.css": "fd733e6b624190da1e70dc1b6613e2376efd7f7570a28fab311d81006d03d960",
53
53
  "public/blog.js": "d01c72a9834dcc7c5241923371f2b164ac30d20ac9f1a5c5dc5b71fa3cb09f08",
54
54
  "public/samples/aurora.svg": "feb29e2b46465913fc29de9eda221b00b1541f1fa3ea7120f0c1d1cd11a9a4f6",
@@ -61,9 +61,9 @@
61
61
  "roles/blog-editor.json": "bea0b673cf9e3da04c5882d51dd33bdcaf035b047c1cfe87624a9f16e6514ffc",
62
62
  "templates/index.html": "3c2cf8a4ffde8599d451f6d00b736aeba42fd073b726ada3b0ffe4d56e1ef0f4",
63
63
  "templates/post.html": "28e9f06071115ecc4fbba007b35782732bf12ef72528525893d424e3c4f0313f",
64
- "tests/layouts.test.js": "f6dc1ac17b1d4841accd673ab34cc127aa2ccdbca453ef1cee8dbb7a637669a6",
65
- "tests/nav-link.test.js": "d25a2ec7948df4b042c402e8c5f2cacb2be4743edba48d497a6b67a0ca1fdd5a",
66
- "tests/public.test.js": "ef04ba271d03cee86adac332c6834bb51102b20d892d5f84d30eb04507605de5"
64
+ "tests/layouts.test.js": "5325aad79a153f1d17f49b27223f50be182a2acce8ee8be7da658ae92ae36fe8",
65
+ "tests/nav-link.test.js": "d36e33ad7483abca27ae6e2098ad2f75bafa18d91f3beba70372ebe6b9b88994",
66
+ "tests/public.test.js": "0049a6be16c6a8acb44d53ea885909c80563a0afaa3e1e89d3c54d5429feed98"
67
67
  }
68
68
  },
69
69
  "mail-reader": {
@@ -159,6 +159,13 @@ export function canManageUser(actor, target) {
159
159
  * lets both roles in, plus anyone more privileged than either (typically
160
160
  * admins inherit access automatically via the level comparison).
161
161
  *
162
+ * Exact tokens: a role name prefixed with `=` (`=candidate`) skips the
163
+ * hierarchy and passes only for users who HOLD that role, primary or
164
+ * additional. That is the only way to keep two roles on the same level (or
165
+ * a lower-privilege role and a higher one) out of each other's pages and menu
166
+ * items. The root role (level 0) still passes, as it does for 'private'.
167
+ * Exact and hierarchical tokens mix freely in an array.
168
+ *
162
169
  * @param {string|null} userRole - The visitor's role, or null if unauthenticated
163
170
  * @param {string|string[]} visibility - Required visibility - single value or array
164
171
  * @returns {boolean} true if access is granted
@@ -194,12 +201,26 @@ export function checkVisibility(userRoleOrObj, visibility) {
194
201
  * @returns {boolean}
195
202
  */
196
203
  function checkSingleVisibility(userRole, visibility) {
204
+ const exact = parseExactVisibility(visibility);
205
+ if (exact) return userRole === exact || getRoleLevel(userRole) === 0;
197
206
  const userLevel = getRoleLevel(userRole);
198
207
  const requiredLevel = getRoleLevel(visibility);
199
208
  const threshold = requiredLevel === Infinity ? 0 : requiredLevel;
200
209
  return userLevel <= threshold;
201
210
  }
202
211
 
212
+ /**
213
+ * The role an exact visibility token names, or null for any other token.
214
+ * `=candidate` → 'candidate'; `candidate`, `public`, `=` → null.
215
+ *
216
+ * @param {*} token
217
+ * @returns {string|null}
218
+ */
219
+ export function parseExactVisibility(token) {
220
+ if (typeof token !== 'string' || token.length < 2 || token[0] !== '=') return null;
221
+ return token.slice(1);
222
+ }
223
+
203
224
  /**
204
225
  * Fastify preHandler factory - gates a route by visibility level.
205
226
  * Works identically to the content-page visibility system; accepts the same
@@ -10,9 +10,10 @@
10
10
  import crypto from 'node:crypto';
11
11
  import {config, getConfig} from '../../config.js';
12
12
  import {hooks} from '../../services/hooks.js';
13
- import {authenticate, getPermissionsForRole} from '../../middleware/auth.js';
14
- import {getRoleLevel} from '../../services/roles.js';
15
- import {GROUP_ORDER, REGISTRY} from '../../services/permissionRegistry.js';
13
+ import {authenticate} from '../../middleware/auth.js';
14
+ import {getAdminScope, getPermissionsForRoles, getRoleLevel} from '../../services/roles.js';
15
+ import {getEffectiveRegistry, GROUP_ORDER} from '../../services/permissionRegistry.js';
16
+ import {getEffectiveRoles} from '../../services/userRoles.js';
16
17
  import {
17
18
  clearResetToken,
18
19
  countUsers,
@@ -118,13 +119,20 @@ export async function authRoutes(fastify) {
118
119
 
119
120
  // GET /api/auth/permissions - returns the raw permissions array for the authenticated user's role
120
121
  fastify.get('/auth/permissions', {preHandler: [authenticate]}, async (request) => {
121
- const permissions = getPermissionsForRole(request.user.role);
122
- return {permissions};
122
+ // Every role the user holds, not just the primary one - the server
123
+ // grants on the union, so the sidebar must hide on the union too.
124
+ const roles = getEffectiveRoles(request.user);
125
+ const allResources = getEffectiveRegistry().map(r => r.key);
126
+ const permissions = getPermissionsForRoles(roles, allResources);
127
+ // adminScope: null = the whole admin; otherwise {home, allow} - the
128
+ // screens this user's roles confine them to (see roles.getAdminScope).
129
+ return {permissions, adminScope: getAdminScope(roles)};
123
130
  });
124
131
 
125
- // GET /api/auth/permissions-registry - returns the full permissions registry
132
+ // GET /api/auth/permissions-registry - returns the full permissions registry,
133
+ // including resources plugins registered, so the role editor can grant them.
126
134
  fastify.get('/auth/permissions-registry', {preHandler: [authenticate]}, async () => {
127
- return {groups: GROUP_ORDER, resources: REGISTRY};
135
+ return {groups: GROUP_ORDER, resources: getEffectiveRegistry()};
128
136
  });
129
137
 
130
138
  // GET /api/auth/me
@@ -202,6 +202,29 @@ export async function checkPublicAccess(schema, operation, request, reply) {
202
202
  }
203
203
  }
204
204
 
205
+ /**
206
+ * Role fields the system owns, not the editor: `plugin` says which plugin owns
207
+ * the role (hooks.unregisterRole and uninstall key off it) and `grantedBy`
208
+ * records one-time plugin grants. Clients send whole records, so without this
209
+ * any save from the role editor silently erased both - the role stopped
210
+ * belonging to its plugin and revoked grants came back on the next boot.
211
+ * Whatever the client sent for them is ignored in favour of what is stored.
212
+ *
213
+ * @param {string} id
214
+ * @param {object} data
215
+ * @returns {Promise<object>}
216
+ */
217
+ const ROLE_SYSTEM_FIELDS = ['plugin', 'grantedBy'];
218
+ async function keepRoleSystemFields(id, data) {
219
+ const existing = (await getEntry('roles', id))?.data || {};
220
+ const out = {...data};
221
+ for (const field of ROLE_SYSTEM_FIELDS) {
222
+ if (existing[field] !== undefined) out[field] = existing[field];
223
+ else delete out[field];
224
+ }
225
+ return out;
226
+ }
227
+
205
228
  export async function collectionsRoutes(fastify) {
206
229
  const canRead = {preHandler: [authenticate, requirePermission('collections', 'read')]};
207
230
  const canCreate = {preHandler: [authenticate, requirePermission('collections', 'create')]};
@@ -347,7 +370,13 @@ export async function collectionsRoutes(fastify) {
347
370
  fastify.post('/collections/:slug/entries', canCreate, async (request, reply) => {
348
371
  const user = request.user;
349
372
  try {
350
- const entry = await createEntry(request.params.slug, request.body?.data || {}, {
373
+ let data = request.body?.data || {};
374
+ // A role created in the admin belongs to no plugin; see keepRoleSystemFields.
375
+ if (request.params.slug === 'roles') {
376
+ data = {...data};
377
+ ROLE_SYSTEM_FIELDS.forEach(field => delete data[field]);
378
+ }
379
+ const entry = await createEntry(request.params.slug, data, {
351
380
  createdBy: user?.id || null,
352
381
  source: 'admin'
353
382
  });
@@ -395,7 +424,9 @@ export async function collectionsRoutes(fastify) {
395
424
 
396
425
  fastify.put('/collections/:slug/entries/:id', canUpdate, async (request, reply) => {
397
426
  try {
398
- const entry = await updateEntry(request.params.slug, request.params.id, request.body?.data || {});
427
+ let data = request.body?.data || {};
428
+ if (request.params.slug === 'roles') data = await keepRoleSystemFields(request.params.id, data);
429
+ const entry = await updateEntry(request.params.slug, request.params.id, data);
399
430
  if (request.params.slug === 'roles') await invalidateRoles();
400
431
  hooks.emit('collection:entryUpdated', { slug: request.params.slug, entryId: entry.id, data: entry.data, userId: request.user?.id || null });
401
432
  return entry;
@@ -701,7 +732,13 @@ export async function collectionsRoutes(fastify) {
701
732
  if (denied !== undefined) return;
702
733
 
703
734
  try {
704
- const entry = await createEntry(request.params.slug, request.body?.data || {}, {
735
+ let data = request.body?.data || {};
736
+ // A role created in the admin belongs to no plugin; see keepRoleSystemFields.
737
+ if (request.params.slug === 'roles') {
738
+ data = {...data};
739
+ ROLE_SYSTEM_FIELDS.forEach(field => delete data[field]);
740
+ }
741
+ const entry = await createEntry(request.params.slug, data, {
705
742
  createdBy: request.apiToken ? `token:${request.apiToken.id}` : (request.user?.id || null),
706
743
  source: 'api'
707
744
  });
@@ -1,23 +1,39 @@
1
1
  /**
2
2
  * Admin sidebar API
3
3
  *
4
- * GET /api/sidebar/registered-items - plugin-registered sidebar items
4
+ * GET /api/sidebar/menu - the admin-sidebar menu, filtered for the caller
5
+ * GET /api/sidebar/registered-items - plugin-registered sidebar items, filtered for the caller
6
+ *
7
+ * Both need only a signed-in user, NOT `menus.read`: every user who can reach
8
+ * the admin needs a sidebar, and a role without menu-editing rights (a
9
+ * `candidate`, say) used to get a 403 here and fall back to a hardcoded tree
10
+ * holding a Menus link they could not open. Items gated by `visibility` are
11
+ * dropped on the server, the same way the public navbar drops them; items
12
+ * gated by `permission` are dropped by the renderer, which holds the list.
5
13
  *
6
14
  * Auth middlewares are accepted as DI options so tests can supply no-ops.
7
15
  */
8
- import {
9
- authenticate as defaultAuthenticate,
10
- requirePermission as defaultRequirePermission
11
- } from '../../middleware/auth.js';
16
+ import {authenticate as defaultAuthenticate, checkVisibility} from '../../middleware/auth.js';
12
17
  import {getRegisteredSidebarItems} from '../../services/hooks.js';
18
+ import {filterItemsForUser, getMenu} from '../../services/menus.js';
19
+
20
+ export const ADMIN_SIDEBAR_SLUG = 'admin-sidebar';
13
21
 
14
22
  export async function sidebarRoutes(fastify, opts = {}) {
15
- const authenticate = opts.authenticate || defaultAuthenticate;
16
- const requirePermission = opts.requirePermission || defaultRequirePermission;
23
+ const authenticate = opts.authenticate || defaultAuthenticate;
24
+ const signedIn = {preHandler: [authenticate]};
17
25
 
18
- const canRead = {preHandler: [authenticate, requirePermission('menus', 'read')]};
26
+ fastify.get('/sidebar/menu', signedIn, async (request) => {
27
+ const menu = await getMenu(ADMIN_SIDEBAR_SLUG);
28
+ if (!menu) return {items: []};
29
+ return {...menu, items: filterItemsForUser(menu.items || [], request.user)};
30
+ });
19
31
 
20
- fastify.get('/sidebar/registered-items', canRead, async () => {
21
- return getRegisteredSidebarItems();
32
+ fastify.get('/sidebar/registered-items', signedIn, async (request) => {
33
+ return getRegisteredSidebarItems()
34
+ .filter(entry => !entry?.item?.hidden && checkVisibility(request.user, entry?.item?.visibility))
35
+ .map(entry => Array.isArray(entry.item?.items)
36
+ ? {...entry, item: {...entry.item, items: filterItemsForUser(entry.item.items, request.user)}}
37
+ : entry);
22
38
  });
23
39
  }
@@ -213,8 +213,12 @@ export async function publicRoutes(fastify) {
213
213
  // response must not reveal that an unpublished page exists there.
214
214
  const isDraft = page.status !== 'published';
215
215
 
216
- // Enforce page visibility - role only resolved for gated pages,
217
- // so public pages share a single cache entry keyed `roleanon`.
216
+ // Enforce page visibility. The viewer is resolved on EVERY request,
217
+ // not only for gated pages: the navbar, footers, overlays and [menu]
218
+ // shortcodes on a public page filter their items by role too, and a
219
+ // signed-in visitor rendered as anonymous never sees the items meant
220
+ // for them. Resolving is a cookie read plus a JWT verify, and returns
221
+ // null at once for anonymous traffic, which still shares one entry.
218
222
  //
219
223
  // `visibility` may be a string ('public' | 'private' | role name) or
220
224
  // an array of role names - see checkVisibility() for full semantics.
@@ -222,25 +226,17 @@ export async function publicRoutes(fastify) {
222
226
  // 'public') skip viewer resolution entirely so anonymous traffic hits
223
227
  // the shared cache without auth cost - unless the page is a draft,
224
228
  // which needs the viewer regardless of its visibility.
225
- // For per-role cache keying we use the primary role only - multi-role
226
- // users still see correctly-gated content (checkVisibility consults
227
- // additionalRoles too) but the cache key stays bounded to one entry
228
- // per primary role rather than 2^N per role combination. The fallback
229
- // is correctness: any user whose access depends on an additional role
230
- // is granted, but their served HTML is the variant cached for their
231
- // primary role's tier - fine for the public-site use case.
232
- let userRole = null;
233
- let userObj = null;
229
+ // The cache key carries every role the viewer holds (sorted), since
230
+ // exact-role visibility (`=candidate`) makes an additional role change
231
+ // what renders. Real sites hold a handful of role combinations, so
232
+ // the entry count stays bounded by what users actually have.
234
233
  const vis = page.visibility;
235
234
  const isPublic = !vis
236
235
  || vis === 'public'
237
236
  || (Array.isArray(vis) && (vis.length === 0 || vis.includes('public')));
238
237
 
239
- // A draft always needs the viewer resolved, whatever its visibility.
240
- if (isDraft || !isPublic) {
241
- userObj = await resolveViewer(request);
242
- userRole = userObj ? userObj.role : null;
243
- }
238
+ const userObj = await resolveViewer(request);
239
+ const userRole = userObj ? [...getEffectiveRoles(userObj)].sort().join('+') : null;
244
240
 
245
241
  if (isDraft && !canPreviewDrafts(userObj)) {
246
242
  reply.status(404);
@@ -256,7 +252,7 @@ export async function publicRoutes(fastify) {
256
252
 
257
253
  // Re-parse with user context so the body's [menu] shortcode sees the
258
254
  // visitor's role for visibility filtering. The first getPage() above
259
- // was anonymous because we hadn't resolved the viewer yet.
255
+ // was anonymous - it only needed the page's metadata.
260
256
  const render = async () => {
261
257
  const pageForRole = await getPage(page.urlPath, {user: userObj}) || page;
262
258
  return renderPage(pageForRole, {
@@ -1082,7 +1082,7 @@ export async function resolveOverlaysForPage(ctx = {}, user = null) {
1082
1082
  .sort((a, b) => String(a.slug).localeCompare(String(b.slug)));
1083
1083
  }
1084
1084
 
1085
- function filterItemsForUser(items, user) {
1085
+ export function filterItemsForUser(items, user) {
1086
1086
  const out = [];
1087
1087
  for (const item of items) {
1088
1088
  if (item.hidden) continue;
@@ -322,21 +322,24 @@ const _pluginContributed = [];
322
322
  * @param {string} plugin - Plugin name that owns this resource
323
323
  */
324
324
  export function registerPluginResource(resource, plugin) {
325
+ if (!resource || typeof resource.key !== 'string' || !resource.key) return;
325
326
  const effective = getEffectiveRegistry();
326
327
  if (effective.some(r => r.key === resource.key)) return;
327
328
 
328
329
  _pluginContributed.push({
329
330
  key: resource.key,
330
- label: resource.label,
331
- description: resource.description || `${resource.label} (${plugin} plugin)`,
331
+ label: resource.label || resource.key,
332
+ description: resource.description || `${resource.label || resource.key} (${plugin} plugin)`,
332
333
  icon: resource.icon || 'box',
333
334
  group: resource.group || 'Plugins',
334
335
  plugin,
335
- actions: resource.actions?.map(a => ({
336
- key: a.key,
337
- label: a.label,
338
- description: a.description || a.label
339
- })) ?? ACTIONS.map(key => ({key, label: key, description: key}))
336
+ // Actions may be objects or bare keys (`["read", "create"]`) - the
337
+ // short form is what plugin role files have always shipped.
338
+ actions: Array.isArray(resource.actions) && resource.actions.length
339
+ ? resource.actions.map(a => typeof a === 'string'
340
+ ? {key: a, label: a, description: a}
341
+ : {key: a.key, label: a.label || a.key, description: a.description || a.label || a.key})
342
+ : ACTIONS.map(key => ({key, label: key, description: key}))
340
343
  });
341
344
  }
342
345
 
@@ -16,7 +16,7 @@ import path from 'path';
16
16
  import zlib from 'zlib';
17
17
  import {fileURLToPath} from 'url';
18
18
  import {getConfig} from '../config.js';
19
- import {discoverPlugins, validatePluginDir} from './plugins.js';
19
+ import {discoverPlugins, pluginRoleHooks, unregisterAllRolesForPlugin, validatePluginDir} from './plugins.js';
20
20
  import {allowInGitignore, manifestContentPaths, pluginContentRules} from './siteGitignore.js';
21
21
 
22
22
  // ---------------------------------------------------------------------------
@@ -476,10 +476,11 @@ async function upgradePlugin(slug, {tarball, unsigned, entitlement, allowDowngra
476
476
  * Uninstall a plugin by slug.
477
477
  *
478
478
  * Steps:
479
- * 1. Run onDisable lifecycle hook (warn-only on failure)
480
- * 2. Remove plugins/<slug>/ directory
481
- * 3. Remove entry from config/plugins.json
482
- * 4. Append uninstall record to audit log
479
+ * 1. Run onDisable lifecycle hook (warn-only on failure), with `uninstall: true`
480
+ * 2. Remove the roles the plugin registered, moving their users to `user`
481
+ * 3. Remove plugins/<slug>/ directory
482
+ * 4. Remove entry from config/plugins.json
483
+ * 5. Append uninstall record to audit log
483
484
  *
484
485
  * @param {string} slug
485
486
  * @returns {Promise<void>}
@@ -496,23 +497,36 @@ export async function uninstallPlugin(slug) {
496
497
  const pluginJsPath = path.join(destDir, 'plugin.js');
497
498
  const mod = await import(pluginJsPath);
498
499
  if (typeof mod.onDisable === 'function') {
499
- await mod.onDisable(null);
500
+ // Same shape as an ordinary disable (it used to be null, which
501
+ // threw in any onDisable that destructured its argument), plus
502
+ // `uninstall` so a plugin can tell removal from a pause.
503
+ await mod.onDisable({fastify: null, services: {}, hooks: pluginRoleHooks(slug), uninstall: true});
500
504
  }
501
505
  } catch (err) {
502
506
  console.warn(`[pluginInstaller] onDisable hook for "${slug}" failed: ${err.message}`);
503
507
  }
504
508
 
505
- // Step 2 - Remove plugin directory
509
+ // Step 2 - Roles. A disable keeps them so a pause demotes nobody; an
510
+ // uninstall removes whatever the plugin still owns, whether it called
511
+ // unregisterRole itself or not.
512
+ try {
513
+ const removed = await unregisterAllRolesForPlugin(slug);
514
+ if (removed.length) console.log(`[pluginInstaller] Removed roles for "${slug}": ${removed.join(', ')}`);
515
+ } catch (err) {
516
+ console.warn(`[pluginInstaller] Role removal for "${slug}" failed: ${err.message}`);
517
+ }
518
+
519
+ // Step 3 - Remove plugin directory
506
520
  await fs.rm(destDir, { recursive: true, force: true });
507
521
 
508
- // Step 3 - Remove from config/plugins.json
522
+ // Step 4 - Remove from config/plugins.json
509
523
  const cfg = await readPluginsConfig();
510
524
  if (slug in cfg) {
511
525
  delete cfg[slug];
512
526
  await writePluginsConfig(cfg);
513
527
  }
514
528
 
515
- // Step 4 - Audit log
529
+ // Step 5 - Audit log
516
530
  await writeAuditLog({
517
531
  action: 'uninstall',
518
532
  slug,