domma-cms 0.70.0 → 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.
- package/CLAUDE.md +24 -3
- package/admin/css/admin.css +1 -1
- package/admin/js/app.js +3 -3
- package/admin/js/lib/admin-scope.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/templates/page-editor.html +1 -4
- package/admin/js/templates/role-editor.html +28 -0
- package/admin/js/views/menu-editor.js +5 -5
- package/admin/js/views/page-editor.js +26 -26
- package/admin/js/views/role-editor.js +3 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/reader-view.js +2 -0
- package/plugins/_template/plugin.js +5 -2
- package/plugins/blog/CLAUDE.md +20 -5
- package/plugins/blog/admin/views/kit.js +8 -10
- package/plugins/blog/lib/layouts.js +36 -0
- package/plugins/blog/lib/nav-link.js +22 -0
- package/plugins/blog/lib/page.js +13 -1
- package/plugins/blog/plugin.js +28 -9
- package/plugins/blog/plugin.json +6 -6
- package/plugins/blog/plugin.public.js +102 -80
- package/plugins/blog/tests/layouts.test.js +29 -0
- package/plugins/blog/tests/nav-link.test.js +10 -1
- package/plugins/blog/tests/public.test.js +21 -0
- package/plugins/free-tier.lock.json +13 -13
- package/server/middleware/auth.js +21 -0
- package/server/routes/api/auth.js +15 -7
- package/server/routes/api/collections.js +40 -3
- package/server/routes/api/sidebar.js +26 -10
- package/server/routes/public.js +13 -17
- package/server/services/menus.js +1 -1
- package/server/services/permissionRegistry.js +10 -7
- package/server/services/pluginInstaller.js +23 -9
- package/server/services/plugins.js +245 -40
- package/server/services/roles.js +170 -5
|
@@ -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
|
|
646
|
+
// Roles and permissions - see registerPluginAccess()
|
|
638
647
|
if (services.roles) {
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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}`);
|
package/server/services/roles.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
/**
|