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.
@@ -14,7 +14,7 @@ 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, requireVisibility} from '../middleware/auth.js';
17
+ import {authenticate, requireAdmin, requirePermission, requireRole, requireVisibility} from '../middleware/auth.js';
18
18
  import {
19
19
  hooks,
20
20
  registerMenuLocation,
@@ -26,6 +26,9 @@ import {
26
26
  import {registerPluginResource, unregisterPluginResourcesByPlugin} from './permissionRegistry.js';
27
27
  import {classifyEntitlement, mayLoad} from './pluginEntitlement.js';
28
28
  import {createCollection, getCollection} from './collections.js';
29
+ import * as defaultRolesService from './roles.js';
30
+ import {normaliseAdminScope} from './roles.js';
31
+ import * as defaultUsersService from './users.js';
29
32
 
30
33
  const PLUGINS_DIR = path.resolve('plugins');
31
34
 
@@ -413,8 +416,8 @@ export async function registerPlugins(fastify) {
413
416
  const prefix = `/api/plugins/${manifest.name}`;
414
417
  await fastify.register(plugin, {
415
418
  prefix,
416
- auth: {authenticate, requireRole, requireAdmin, requireVisibility},
417
- hooks: {registerShortcode, registerSanitizeRules, registerTransform, registerMenuLocation, registerSidebarItem, on: hooks.on.bind(hooks)},
419
+ auth: {authenticate, requireRole, requireAdmin, requireVisibility, requirePermission},
420
+ hooks: {registerShortcode, registerSanitizeRules, registerTransform, registerMenuLocation, registerSidebarItem, on: hooks.on.bind(hooks), ...pluginRoleHooks(manifest.name)},
418
421
  settings,
419
422
  config: {}
420
423
  });
@@ -440,6 +443,12 @@ export async function registerPlugins(fastify) {
440
443
  await setupPlugin(manifest.name, {collections: {getCollection, createCollection}})
441
444
  .catch(err => fastify.log.warn(`[plugins] Collection setup for "${manifest.name}" failed: ${err.message}`));
442
445
 
446
+ // Permissions are held in memory, so they are registered on every
447
+ // boot, not only on enable (roles are created once, then kept).
448
+ await import('./roles.js')
449
+ .then(rolesService => registerPluginAccess(manifest.name, rolesService))
450
+ .catch(err => fastify.log.warn(`[plugins] Access setup for "${manifest.name}" failed: ${err.message}`));
451
+
443
452
  // Detect optional plugin.public.js for root-level route registration
444
453
  _loadedPlugins[manifest.name] = { enabled: true, publicEntry: null };
445
454
  const publicEntryPath = path.join(PLUGINS_DIR, manifest.name, 'plugin.public.js');
@@ -634,38 +643,240 @@ export async function setupPlugin(pluginName, services) {
634
643
  }
635
644
  }
636
645
 
637
- // Roles - read plugins/<name>/roles/*.json, register resources + persist roles
646
+ // Roles and permissions - see registerPluginAccess()
638
647
  if (services.roles) {
639
- const rolesDir = path.join(pluginDir, 'roles');
640
- let roleFiles;
641
- try {
642
- roleFiles = await fs.readdir(rolesDir);
643
- } catch {
644
- roleFiles = [];
648
+ result.roles.push(...(await registerPluginAccess(pluginName, services.roles)).roles);
649
+ }
650
+
651
+ return result;
652
+ }
653
+
654
+ /**
655
+ * Register what a plugin declares about access: the permission resources it
656
+ * guards its own routes with, the roles it brings, and which of the site's
657
+ * existing roles should start out holding its permissions.
658
+ *
659
+ * Two places declare these, and both are read:
660
+ *
661
+ * plugin.json "permissions": [{key, label?, actions?, group?, grant?}]
662
+ * roles/*.json {name, label, level, permissions, badgeClass?, resources?: [...]}
663
+ *
664
+ * A resource's `grant: ["admin"]` gives those roles the permission once; a
665
+ * role file creates its role once. After that both belong to the admin: an
666
+ * edited role is never overwritten and a revoked grant is never re-added.
667
+ *
668
+ * Resources live in memory, so this runs on every boot for each enabled
669
+ * plugin as well as on enable - otherwise a plugin's permissions vanish from
670
+ * the role editor after the first restart.
671
+ *
672
+ * @param {string} pluginName
673
+ * @param {object} rolesService - server/services/roles.js (injectable for tests)
674
+ * @returns {Promise<{roles: string[], resources: string[]}>}
675
+ */
676
+ export async function registerPluginAccess(pluginName, rolesService) {
677
+ const out = {roles: [], resources: []};
678
+ const pluginDir = path.join(PLUGINS_DIR, pluginName);
679
+ const grants = [];
680
+
681
+ const addResource = (resource) => {
682
+ if (!resource || typeof resource.key !== 'string' || !resource.key) return;
683
+ const {grant, ...definition} = resource;
684
+ registerPluginResource(definition, pluginName);
685
+ out.resources.push(resource.key);
686
+ for (const role of Array.isArray(grant) ? grant : []) {
687
+ if (typeof role === 'string' && role) grants.push([role, resource.key]);
645
688
  }
646
- for (const file of roleFiles.filter(f => f.endsWith('.json'))) {
647
- try {
648
- const raw = await fs.readFile(path.join(rolesDir, file), 'utf8');
649
- const def = JSON.parse(raw);
650
-
651
- // Register any declared resources into the permission overlay
652
- if (Array.isArray(def.resources)) {
653
- for (const resource of def.resources) {
654
- registerPluginResource(resource, pluginName);
655
- }
656
- }
689
+ };
657
690
 
658
- // Persist the role (idempotent)
659
- const {resources: _resources, ...roleData} = def;
660
- await services.roles.createRole({...roleData, plugin: pluginName});
661
- result.roles.push(def.name);
662
- } catch {
663
- // Skip missing or invalid role files
691
+ try {
692
+ const manifest = JSON.parse(await fs.readFile(path.join(pluginDir, 'plugin.json'), 'utf8'));
693
+ if (Array.isArray(manifest.permissions)) manifest.permissions.forEach(addResource);
694
+ } catch { /* no manifest permissions */ }
695
+
696
+ let roleFiles = [];
697
+ try {
698
+ roleFiles = (await fs.readdir(path.join(pluginDir, 'roles'))).filter(f => f.endsWith('.json'));
699
+ } catch { /* no roles dir */ }
700
+
701
+ const roleDefs = [];
702
+ for (const file of roleFiles) {
703
+ try {
704
+ const def = JSON.parse(await fs.readFile(path.join(pluginDir, 'roles', file), 'utf8'));
705
+ if (Array.isArray(def.resources)) def.resources.forEach(addResource);
706
+ if (typeof def.name === 'string' && def.name) roleDefs.push(def);
707
+ } catch { /* skip an unreadable role file */ }
708
+ }
709
+
710
+ for (const def of roleDefs) {
711
+ const {resources: _resources, ...roleData} = def;
712
+ if (validateRoleDef(roleData)) continue;
713
+ try {
714
+ const ensure = rolesService.ensureRole || rolesService.createRole;
715
+ await ensure({...roleData, plugin: pluginName});
716
+ out.roles.push(def.name);
717
+ } catch { /* skip */ }
718
+ }
719
+
720
+ for (const [role, permission] of grants) {
721
+ try {
722
+ await rolesService.grantPermissionOnce?.(role, permission, pluginName);
723
+ } catch { /* a role that does not exist is simply not granted */ }
724
+ }
725
+
726
+ // Bare permissions ('blog-posts') expand to the resource's actions when
727
+ // the cache is built, which happened before these resources existed.
728
+ if (out.resources.length) await rolesService.invalidate?.();
729
+ return out;
730
+ }
731
+
732
+ /** Role names: what a role slug has always looked like in the roles collection. */
733
+ const ROLE_NAME_RE = /^[a-z0-9][a-z0-9-_]{0,63}$/;
734
+
735
+ /**
736
+ * Why a role definition is unusable, or null if it is fine. Level 0 is the
737
+ * root role; a plugin can never mint another one.
738
+ *
739
+ * @param {object} def
740
+ * @returns {string|null}
741
+ */
742
+ function validateRoleDef(def) {
743
+ if (!def || typeof def !== 'object') return 'a role definition object is required';
744
+ if (typeof def.name !== 'string' || !ROLE_NAME_RE.test(def.name)) {
745
+ return 'name must be lowercase letters, digits, "-" or "_" (max 64)';
746
+ }
747
+ if (!Number.isInteger(def.level) || def.level < 1) return 'level must be a whole number of 1 or more';
748
+ if (def.label != null && typeof def.label !== 'string') return 'label must be a string';
749
+ if (def.permissions != null && !(Array.isArray(def.permissions) && def.permissions.every(p => typeof p === 'string'))) {
750
+ return 'permissions must be an array of strings';
751
+ }
752
+ if (def.admin != null && !normaliseAdminScope(def.admin)) {
753
+ return 'admin must be {home?, allow: ["/route", ...]} with at least one /-rooted route';
754
+ }
755
+ return null;
756
+ }
757
+
758
+ /**
759
+ * Register a role on behalf of a plugin - the code behind `hooks.registerRole()`.
760
+ *
761
+ * Same rules as a `roles/*.json` file: created once, then the admin's. Calling
762
+ * it again (every boot, say) is a no-op for a role this plugin already owns,
763
+ * so an admin's edits survive. A name held by the site or by another plugin is
764
+ * refused with an error rather than skipped - a plugin asking for `admin`
765
+ * should find out, not silently share the site's role.
766
+ *
767
+ * `resources` (permission definitions, as in plugin.json `permissions`) are
768
+ * registered too, `grant` included.
769
+ *
770
+ * @param {string} pluginName
771
+ * @param {{name:string,label?:string,level:number,permissions?:string[],badgeClass?:string,resources?:object[]}} def
772
+ * @param {object} [rolesService]
773
+ * @returns {Promise<{name: string, created: boolean}>}
774
+ */
775
+ export async function registerRoleForPlugin(pluginName, def, rolesService = defaultRolesService) {
776
+ if (!def || typeof def !== 'object') throw new Error('registerRole: a role definition object is required');
777
+ const {resources, ...roleData} = def;
778
+ const problem = validateRoleDef(roleData);
779
+ if (problem) throw new Error(`registerRole: ${problem}`);
780
+
781
+ const existing = await rolesService.getRoleData(roleData.name);
782
+ if (existing && existing.plugin !== pluginName) {
783
+ throw new Error(`registerRole: role "${roleData.name}" already exists and does not belong to plugin "${pluginName}"`);
784
+ }
785
+
786
+ let registered = false;
787
+ for (const resource of Array.isArray(resources) ? resources : []) {
788
+ if (!resource || typeof resource.key !== 'string' || !resource.key) continue;
789
+ const {grant, ...definition} = resource;
790
+ registerPluginResource(definition, pluginName);
791
+ registered = true;
792
+ for (const role of Array.isArray(grant) ? grant : []) {
793
+ if (typeof role === 'string' && role) {
794
+ await rolesService.grantPermissionOnce(role, resource.key, pluginName).catch(() => {});
664
795
  }
665
796
  }
666
797
  }
667
798
 
668
- return result;
799
+ const created = existing ? false : await rolesService.ensureRole({
800
+ label: roleData.name, permissions: [], ...roleData, plugin: pluginName
801
+ });
802
+ if (registered) await rolesService.invalidate();
803
+ return {name: roleData.name, created: !!created};
804
+ }
805
+
806
+ /**
807
+ * Remove a role a plugin registered - the code behind `hooks.unregisterRole()`.
808
+ *
809
+ * Only the owning plugin can remove its role; the site's roles and other
810
+ * plugins' roles are refused. Users are moved off the role in the same step:
811
+ * one holding it as their primary role becomes `user` (the base role that
812
+ * always exists), and it is dropped from everyone's additional roles. That is
813
+ * what the next boot would do anyway (seed() demotes unknown roles), done now
814
+ * so nobody is left holding a role that grants nothing.
815
+ *
816
+ * @param {string} pluginName
817
+ * @param {string} name
818
+ * @param {{rolesService?: object, usersService?: object}} [services]
819
+ * @returns {Promise<{name: string, removed: boolean, usersReassigned: number}>}
820
+ */
821
+ export async function unregisterRoleForPlugin(pluginName, name, {
822
+ rolesService = defaultRolesService,
823
+ usersService = defaultUsersService
824
+ } = {}) {
825
+ const existing = await rolesService.getRoleData(name);
826
+ if (!existing) return {name, removed: false, usersReassigned: 0};
827
+ if (existing.plugin !== pluginName) {
828
+ throw new Error(`unregisterRole: role "${name}" does not belong to plugin "${pluginName}"`);
829
+ }
830
+
831
+ await rolesService.removeRole(name);
832
+
833
+ let usersReassigned = 0;
834
+ for (const user of await usersService.listUsers()) {
835
+ const additional = Array.isArray(user.additionalRoles) ? user.additionalRoles : [];
836
+ const holdsPrimary = user.role === name;
837
+ if (!holdsPrimary && !additional.includes(name)) continue;
838
+ await usersService.updateUser(user.id, {
839
+ ...(holdsPrimary && {role: 'user'}),
840
+ additionalRoles: additional.filter(r => r !== name)
841
+ });
842
+ usersReassigned++;
843
+ }
844
+ return {name, removed: true, usersReassigned};
845
+ }
846
+
847
+ /**
848
+ * Remove every role a plugin owns - used when the plugin is uninstalled.
849
+ * (Disabling keeps them; see teardownPlugin.)
850
+ *
851
+ * @param {string} pluginName
852
+ * @param {{rolesService?: object, usersService?: object}} [services]
853
+ * @returns {Promise<string[]>} the removed role names
854
+ */
855
+ export async function unregisterAllRolesForPlugin(pluginName, services = {}) {
856
+ const rolesService = services.rolesService || defaultRolesService;
857
+ const owned = [...rolesService.getRoleMap().keys()];
858
+ const removed = [];
859
+ for (const name of owned) {
860
+ const data = await rolesService.getRoleData(name);
861
+ if (data?.plugin !== pluginName) continue;
862
+ const res = await unregisterRoleForPlugin(pluginName, name, services);
863
+ if (res.removed) removed.push(name);
864
+ }
865
+ return removed;
866
+ }
867
+
868
+ /**
869
+ * The role hooks handed to one plugin, bound to its name so it can only ever
870
+ * register and remove roles of its own.
871
+ *
872
+ * @param {string} pluginName
873
+ * @returns {{registerRole: Function, unregisterRole: Function}}
874
+ */
875
+ export function pluginRoleHooks(pluginName) {
876
+ return {
877
+ registerRole: (def) => registerRoleForPlugin(pluginName, def),
878
+ unregisterRole: (name) => unregisterRoleForPlugin(pluginName, name)
879
+ };
669
880
  }
670
881
 
671
882
  /**
@@ -714,16 +925,11 @@ export async function teardownPlugin(pluginName, services, fastify) {
714
925
  }
715
926
  }
716
927
 
717
- // Roles - remove plugin-contributed roles and unregister their resources
718
- if (services.roles) {
719
- try {
720
- await services.roles.removeRolesByPlugin(pluginName);
721
- unregisterPluginResourcesByPlugin(pluginName);
722
- result.roles.push(pluginName);
723
- } catch (err) {
724
- fastify.log.warn(`[plugins] Could not remove roles for "${pluginName}": ${err.message}`);
725
- }
726
- }
928
+ // Permissions - unregister the plugin's resources. Its roles are KEPT: a
929
+ // role removed here would leave every user holding it with a role the
930
+ // next boot's migrateUserRoles() rewrites to 'user', so a disable/enable
931
+ // cycle would silently demote them. Like collections, they stay put.
932
+ unregisterPluginResourcesByPlugin(pluginName);
727
933
 
728
934
  if (_loadedPlugins[pluginName]) {
729
935
  _loadedPlugins[pluginName].enabled = false;
@@ -797,11 +1003,10 @@ export async function runLifecycleHook(name, hook, fastify) {
797
1003
  const torn = await teardownPlugin(name, services, fastify);
798
1004
  if (torn.pages.length) fastify.log.info(`[plugins] Removed pages for "${name}": ${torn.pages.join(', ')}`);
799
1005
  if (torn.forms.length) fastify.log.info(`[plugins] Removed forms for "${name}": ${torn.forms.join(', ')}`);
800
- if (torn.roles.length) fastify.log.info(`[plugins] Removed roles for "${name}"`);
801
1006
  }
802
1007
 
803
1008
  if (typeof mod[hook] === 'function') {
804
- await mod[hook]({ fastify, services });
1009
+ await mod[hook]({ fastify, services, hooks: pluginRoleHooks(name) });
805
1010
  }
806
1011
  } catch (err) {
807
1012
  fastify.log.error(`Plugin "${name}" lifecycle hook "${hook}" failed: ${err.message}`);
@@ -4,7 +4,9 @@
4
4
  * Auth middleware calls getRoleMap() / getPermissionsFor() at request time.
5
5
  *
6
6
  * Base roles: super-admin (0), admin (1), user (2).
7
- * Plugin-contributed roles are tagged with a `plugin` field and removed on plugin teardown.
7
+ * Plugin-contributed roles are tagged with a `plugin` field. They are created
8
+ * once (`ensureRole`) and then belong to the admin; disabling the plugin leaves
9
+ * them in place so user assignments survive a disable/enable cycle.
8
10
  */
9
11
  import fs from 'fs/promises';
10
12
  import path from 'path';
@@ -100,6 +102,9 @@ let permissionsMap = new Map();
100
102
  /** @type {Map<string,string[]>} role name → raw permissions array */
101
103
  let rawPermissionsMap = new Map();
102
104
 
105
+ /** @type {string[]} names of level-0 roles - they hold every permission, including plugin ones */
106
+ let rootRoles = [];
107
+
103
108
  /**
104
109
  * Build in-memory maps from an array of data entries.
105
110
  * Supports both bare resource names ('pages') and dotted action strings ('pages.read').
@@ -111,6 +116,7 @@ function buildCache(entries) {
111
116
  roleMap = new Map();
112
117
  permissionsMap = new Map();
113
118
  rawPermissionsMap = new Map();
119
+ rootRoles = entries.filter(e => e.data?.level === 0).map(e => e.data.name);
114
120
 
115
121
  const addTo = (key, role) => {
116
122
  if (!permissionsMap.has(key)) permissionsMap.set(key, []);
@@ -119,11 +125,13 @@ function buildCache(entries) {
119
125
 
120
126
  for (const entry of entries) {
121
127
  const d = entry.data;
128
+ const adminScope = d.level === 0 ? null : normaliseAdminScope(d.admin);
122
129
  roleMap.set(d.name, {
123
130
  label: d.label,
124
131
  level: d.level,
125
132
  badgeClass: d.badgeClass || '',
126
- ...(d.meta != null && {meta: d.meta})
133
+ ...(d.meta != null && {meta: d.meta}),
134
+ ...(adminScope && {admin: adminScope})
127
135
  });
128
136
  rawPermissionsMap.set(d.name, d.permissions || []);
129
137
  for (const perm of (d.permissions || [])) {
@@ -312,6 +320,141 @@ export async function createRole(data) {
312
320
  buildCache(entries);
313
321
  }
314
322
 
323
+ /** An admin route: `/`-rooted, hash-router path characters only. */
324
+ const ADMIN_PATH_RE = /^\/[A-Za-z0-9\-_/]*$/;
325
+
326
+ /**
327
+ * Validate a role's `admin` block - which admin screens the role is confined
328
+ * to - and return it in canonical form, or null if it is absent or unusable.
329
+ *
330
+ * {"home": "/portal", "allow": ["/portal"]}
331
+ *
332
+ * `allow` lists route prefixes (`/portal` also admits `/portal/orders`).
333
+ * `home` is where a user is sent from anywhere else; it defaults to the first
334
+ * allowed route and is always itself allowed. A block with no usable route is
335
+ * treated as absent - confining someone to nothing would lock them out.
336
+ *
337
+ * @param {*} raw
338
+ * @returns {{home: string, allow: string[]}|null}
339
+ */
340
+ export function normaliseAdminScope(raw) {
341
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
342
+ const clean = p => (typeof p === 'string' && ADMIN_PATH_RE.test(p.trim()))
343
+ ? (p.trim().replace(/\/+$/, '') || '/')
344
+ : null;
345
+ const allow = [...new Set((Array.isArray(raw.allow) ? raw.allow : []).map(clean).filter(Boolean))];
346
+ const home = clean(raw.home) || allow[0] || null;
347
+ if (!home) return null;
348
+ if (!allow.includes(home)) allow.unshift(home);
349
+ return {home, allow};
350
+ }
351
+
352
+ /**
353
+ * The admin scope for a user holding `roleNames`, or null for unrestricted.
354
+ *
355
+ * A user is confined only when EVERY role they hold is: one unscoped role
356
+ * (an `admin` additional role, say) lifts the restriction, because roles
357
+ * grant, they never take away. The level-0 role is never confined. When
358
+ * confined, the allowed routes are the union across roles and `home` is the
359
+ * first role's (the primary role comes first).
360
+ *
361
+ * @param {string[]} roleNames
362
+ * @returns {{home: string, allow: string[]}|null}
363
+ */
364
+ export function getAdminScope(roleNames) {
365
+ const scopes = [];
366
+ for (const name of roleNames || []) {
367
+ const role = roleMap.get(name);
368
+ if (!role) continue; // an unknown role grants nothing either way
369
+ if (role.level === 0 || !role.admin) return null;
370
+ scopes.push(role.admin);
371
+ }
372
+ if (!scopes.length) return null;
373
+ return {
374
+ home: scopes[0].home,
375
+ allow: [...new Set(scopes.flatMap(sc => sc.allow))]
376
+ };
377
+ }
378
+
379
+ /**
380
+ * A role's stored data (as saved, including `plugin` and `grantedBy`), or null.
381
+ *
382
+ * @param {string} name
383
+ * @returns {Promise<object|null>}
384
+ */
385
+ export async function getRoleData(name) {
386
+ try {
387
+ return (await readData()).find(e => e.data?.name === name)?.data ?? null;
388
+ } catch {
389
+ return null;
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Create a role only if no role of that name exists yet.
395
+ *
396
+ * This is what plugins use on every enable and every boot. A role the admin
397
+ * has since edited (label, level, permissions) is left exactly as they left
398
+ * it - the plugin file is the starting point, not a setting that wins.
399
+ *
400
+ * @param {{name:string,label:string,level:number,permissions?:string[],badgeClass?:string,plugin?:string}} data
401
+ * @returns {Promise<boolean>} true if the role was created
402
+ */
403
+ export async function ensureRole(data) {
404
+ let entries;
405
+ try {
406
+ entries = await readData();
407
+ } catch {
408
+ entries = [];
409
+ }
410
+ if (entries.some(e => e.data?.name === data.name)) return false;
411
+ entries.push(makeEntry({permissions: [], ...data}));
412
+ await writeData(entries);
413
+ buildCache(entries);
414
+ return true;
415
+ }
416
+
417
+ /**
418
+ * Grant a permission to an existing role ONCE per source.
419
+ *
420
+ * A plugin that wants the site's own roles (say `admin`) to be able to use
421
+ * its screens calls this for each. The grant is recorded on the role under
422
+ * `grantedBy`, so if the admin later removes the permission it stays
423
+ * removed - the plugin does not re-add it on the next boot.
424
+ *
425
+ * @param {string} roleName
426
+ * @param {string} permission - `resource` or `resource.action`
427
+ * @param {string} source - usually the plugin name
428
+ * @returns {Promise<boolean>} true if the role changed
429
+ */
430
+ export async function grantPermissionOnce(roleName, permission, source) {
431
+ let entries;
432
+ try {
433
+ entries = await readData();
434
+ } catch {
435
+ return false;
436
+ }
437
+ const entry = entries.find(e => e.data?.name === roleName);
438
+ if (!entry) return false;
439
+
440
+ const marker = `${source}:${permission}`;
441
+ const grantedBy = Array.isArray(entry.data.grantedBy) ? entry.data.grantedBy : [];
442
+ if (grantedBy.includes(marker)) return false;
443
+
444
+ const perms = entry.data.permissions || [];
445
+ const [resource] = permission.split('.');
446
+ const alreadyHeld = perms.includes(permission) || perms.includes(resource);
447
+ entry.data = {
448
+ ...entry.data,
449
+ permissions: alreadyHeld ? perms : [...perms, permission],
450
+ grantedBy: [...grantedBy, marker]
451
+ };
452
+ entry.updatedAt = new Date().toISOString();
453
+ await writeData(entries);
454
+ buildCache(entries);
455
+ return true;
456
+ }
457
+
315
458
  /**
316
459
  * Remove a role entry by name and rebuild the cache.
317
460
  * Refuses to remove base roles (level 0 protection is enforced elsewhere;
@@ -383,9 +526,31 @@ export function getRoleLevel(roleName) {
383
526
  * @returns {string[]}
384
527
  */
385
528
  export function getPermissionsFor(resource, action) {
386
- if (action) return permissionsMap.get(`${resource}.${action}`) ?? [];
387
- if (resource.includes('.')) return permissionsMap.get(resource) ?? [];
388
- return permissionsMap.get(resource) ?? [];
529
+ const key = action ? `${resource}.${action}` : resource;
530
+ const granted = permissionsMap.get(key) ?? [];
531
+ // The root role is seeded with the base registry only, so a resource a
532
+ // plugin registers later would otherwise lock out the one role that is
533
+ // meant to be able to do everything.
534
+ const missingRoot = rootRoles.filter(r => !granted.includes(r));
535
+ return missingRoot.length ? [...granted, ...missingRoot] : granted;
536
+ }
537
+
538
+ /**
539
+ * The permissions a set of roles holds between them - the union of each
540
+ * role's raw list, so a user with additional roles sees everything any of
541
+ * them grants. A level-0 role holds every resource in the effective registry.
542
+ *
543
+ * @param {string[]} roleNames
544
+ * @param {string[]} [allResources] - effective registry keys, for the root role
545
+ * @returns {string[]}
546
+ */
547
+ export function getPermissionsForRoles(roleNames, allResources = RESOURCES) {
548
+ const out = new Set();
549
+ for (const name of roleNames || []) {
550
+ if (rootRoles.includes(name)) allResources.forEach(r => out.add(r));
551
+ for (const perm of rawPermissionsMap.get(name) ?? []) out.add(perm);
552
+ }
553
+ return [...out];
389
554
  }
390
555
 
391
556
  /**