@open-mercato/shared 0.5.1-develop.2953.6647bb2c43 → 0.5.1-develop.2964.d5ac4a6ebb
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/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/security/enabledModulesRegistry.js +55 -11
- package/dist/security/enabledModulesRegistry.js.map +2 -2
- package/package.json +2 -2
- package/src/security/__tests__/enabledModulesRegistry.test.ts +59 -1
- package/src/security/enabledModulesRegistry.ts +80 -13
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.5.1-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.5.1-develop.2964.d5ac4a6ebb'\nexport const appVersion = APP_VERSION\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,29 +1,73 @@
|
|
|
1
1
|
import { getModules } from "../lib/modules/registry.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
let cachedRegistry = null;
|
|
3
|
+
let cachedModulesRef = null;
|
|
4
|
+
function buildRegistry(modules) {
|
|
5
|
+
const enabledModuleIds = modules.map((mod) => mod.id);
|
|
6
|
+
const enabledModuleSet = new Set(enabledModuleIds);
|
|
7
|
+
const featureToModule = /* @__PURE__ */ new Map();
|
|
8
|
+
const prefixToModule = /* @__PURE__ */ new Map();
|
|
9
|
+
for (const mod of modules) {
|
|
10
|
+
const features = mod.features;
|
|
11
|
+
if (!Array.isArray(features)) continue;
|
|
12
|
+
for (const feature of features) {
|
|
13
|
+
if (!feature || typeof feature.id !== "string" || !feature.id) continue;
|
|
14
|
+
const declared = typeof feature.module === "string" && feature.module.length > 0 ? feature.module : mod.id;
|
|
15
|
+
featureToModule.set(feature.id, declared);
|
|
16
|
+
const dot = feature.id.indexOf(".");
|
|
17
|
+
if (dot > 0) {
|
|
18
|
+
const prefix = feature.id.slice(0, dot);
|
|
19
|
+
if (!prefixToModule.has(prefix)) prefixToModule.set(prefix, declared);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { enabledModuleIds, enabledModuleSet, featureToModule, prefixToModule };
|
|
5
24
|
}
|
|
6
|
-
function
|
|
25
|
+
function getRegistry() {
|
|
7
26
|
try {
|
|
8
|
-
|
|
27
|
+
const modules = getModules();
|
|
28
|
+
if (cachedRegistry && cachedModulesRef === modules) return cachedRegistry;
|
|
29
|
+
cachedModulesRef = modules;
|
|
30
|
+
cachedRegistry = buildRegistry(modules);
|
|
31
|
+
return cachedRegistry;
|
|
9
32
|
} catch {
|
|
10
33
|
return null;
|
|
11
34
|
}
|
|
12
35
|
}
|
|
36
|
+
function getOwningModuleId(featureId) {
|
|
37
|
+
const registry = getRegistry();
|
|
38
|
+
if (registry) {
|
|
39
|
+
const direct = registry.featureToModule.get(featureId);
|
|
40
|
+
if (direct) return direct;
|
|
41
|
+
if (featureId.endsWith(".*")) {
|
|
42
|
+
const prefix = featureId.slice(0, -2);
|
|
43
|
+
const fromPrefix = registry.prefixToModule.get(prefix);
|
|
44
|
+
if (fromPrefix) return fromPrefix;
|
|
45
|
+
return prefix;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const dot = featureId.indexOf(".");
|
|
49
|
+
return dot === -1 ? featureId : featureId.slice(0, dot);
|
|
50
|
+
}
|
|
13
51
|
function getEnabledModuleIds() {
|
|
14
|
-
|
|
52
|
+
const registry = getRegistry();
|
|
53
|
+
return registry ? [...registry.enabledModuleIds] : [];
|
|
15
54
|
}
|
|
16
55
|
function filterGrantsByEnabledModules(granted) {
|
|
17
|
-
const
|
|
18
|
-
if (
|
|
19
|
-
const
|
|
56
|
+
const registry = getRegistry();
|
|
57
|
+
if (!registry) return [...granted];
|
|
58
|
+
const { enabledModuleIds, enabledModuleSet, prefixToModule } = registry;
|
|
20
59
|
const result = [];
|
|
21
60
|
for (const grant of granted) {
|
|
22
61
|
if (grant === "*") {
|
|
23
|
-
for (const id of
|
|
62
|
+
for (const id of enabledModuleIds) result.push(`${id}.*`);
|
|
63
|
+
for (const [prefix, owningModule] of prefixToModule) {
|
|
64
|
+
if (!enabledModuleSet.has(prefix) && enabledModuleSet.has(owningModule)) {
|
|
65
|
+
result.push(`${prefix}.*`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
24
68
|
continue;
|
|
25
69
|
}
|
|
26
|
-
if (
|
|
70
|
+
if (enabledModuleSet.has(getOwningModuleId(grant))) result.push(grant);
|
|
27
71
|
}
|
|
28
72
|
return result;
|
|
29
73
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/security/enabledModulesRegistry.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Module-aware grant filtering.\n *\n * Features live under `<module>.<action>` (see AGENTS.md naming convention).\n * When a module is disabled in `modules.ts`, its routes/UI are absent but\n * roles may still carry the feature string. Anywhere we turn raw ACL\n * grants into \"what the user can currently act on\", we must drop grants\n * whose owning module is not enabled \u2014 otherwise stale grants re-open the\n * 404-class bug PR #1567 only partially fixed.\n *\n * This helper is server-only: it reads the enabled module set from the\n * bootstrapped module registry. The browser never imports it; instead,\n * server code pre-filters `BackendChromePayload.grantedFeatures` so\n * client-side `hasFeature` can stay a pure grant check.\n */\n\nimport { getModules } from '../lib/modules/registry'\n\
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["/**\n * Module-aware grant filtering.\n *\n * Features live under `<module>.<action>` (see AGENTS.md naming convention).\n * When a module is disabled in `modules.ts`, its routes/UI are absent but\n * roles may still carry the feature string. Anywhere we turn raw ACL\n * grants into \"what the user can currently act on\", we must drop grants\n * whose owning module is not enabled \u2014 otherwise stale grants re-open the\n * 404-class bug PR #1567 only partially fixed.\n *\n * Owning-module resolution:\n * - Most features follow the convention so `id.split('.')[0]` matches the\n * module id. For those, the prefix is correct.\n * - A few features (e.g. `analytics.view`) deliberately use a different\n * namespace from their owning module. For those, the registry's declared\n * `module` field on the `Module.features` entry is authoritative. We\n * consult it first and fall back to the prefix only when the feature is\n * unknown to the registry.\n *\n * This helper is server-only: it reads the enabled module set from the\n * bootstrapped module registry. The browser never imports it; instead,\n * server code pre-filters `BackendChromePayload.grantedFeatures` so\n * client-side `hasFeature` can stay a pure grant check.\n */\n\nimport { getModules } from '../lib/modules/registry'\nimport type { Module } from '../modules/registry'\n\ntype FeatureRegistry = {\n enabledModuleIds: string[]\n enabledModuleSet: Set<string>\n featureToModule: Map<string, string>\n prefixToModule: Map<string, string>\n}\n\nlet cachedRegistry: FeatureRegistry | null = null\nlet cachedModulesRef: readonly Module[] | null = null\n\nfunction buildRegistry(modules: readonly Module[]): FeatureRegistry {\n const enabledModuleIds = modules.map((mod) => mod.id)\n const enabledModuleSet = new Set(enabledModuleIds)\n const featureToModule = new Map<string, string>()\n const prefixToModule = new Map<string, string>()\n for (const mod of modules) {\n const features = mod.features\n if (!Array.isArray(features)) continue\n for (const feature of features) {\n if (!feature || typeof feature.id !== 'string' || !feature.id) continue\n const declared = typeof feature.module === 'string' && feature.module.length > 0\n ? feature.module\n : mod.id\n featureToModule.set(feature.id, declared)\n const dot = feature.id.indexOf('.')\n if (dot > 0) {\n const prefix = feature.id.slice(0, dot)\n if (!prefixToModule.has(prefix)) prefixToModule.set(prefix, declared)\n }\n }\n }\n return { enabledModuleIds, enabledModuleSet, featureToModule, prefixToModule }\n}\n\nfunction getRegistry(): FeatureRegistry | null {\n try {\n const modules = getModules() as readonly Module[]\n if (cachedRegistry && cachedModulesRef === modules) return cachedRegistry\n cachedModulesRef = modules\n cachedRegistry = buildRegistry(modules)\n return cachedRegistry\n } catch {\n return null\n }\n}\n\nexport function getOwningModuleId(featureId: string): string {\n const registry = getRegistry()\n if (registry) {\n const direct = registry.featureToModule.get(featureId)\n if (direct) return direct\n if (featureId.endsWith('.*')) {\n const prefix = featureId.slice(0, -2)\n const fromPrefix = registry.prefixToModule.get(prefix)\n if (fromPrefix) return fromPrefix\n return prefix\n }\n }\n const dot = featureId.indexOf('.')\n return dot === -1 ? featureId : featureId.slice(0, dot)\n}\n\nexport function getEnabledModuleIds(): string[] {\n const registry = getRegistry()\n return registry ? [...registry.enabledModuleIds] : []\n}\n\n/**\n * Filters a raw granted-features list down to the grants whose owning\n * module is currently enabled. Expands `*` (superadmin) into one wildcard\n * per enabled module so the result is still safe to feed into a pure\n * `matchFeature` check, plus one wildcard per off-convention feature\n * prefix (e.g. `analytics.*`) whose declared owning module is enabled.\n * If the module registry is not populated (tests, CLI), returns the input\n * unchanged \u2014 preserves legacy behavior.\n */\nexport function filterGrantsByEnabledModules(granted: readonly string[]): string[] {\n const registry = getRegistry()\n if (!registry) return [...granted]\n const { enabledModuleIds, enabledModuleSet, prefixToModule } = registry\n const result: string[] = []\n for (const grant of granted) {\n if (grant === '*') {\n for (const id of enabledModuleIds) result.push(`${id}.*`)\n for (const [prefix, owningModule] of prefixToModule) {\n if (!enabledModuleSet.has(prefix) && enabledModuleSet.has(owningModule)) {\n result.push(`${prefix}.*`)\n }\n }\n continue\n }\n if (enabledModuleSet.has(getOwningModuleId(grant))) result.push(grant)\n }\n return result\n}\n"],
|
|
5
|
+
"mappings": "AAyBA,SAAS,kBAAkB;AAU3B,IAAI,iBAAyC;AAC7C,IAAI,mBAA6C;AAEjD,SAAS,cAAc,SAA6C;AAClE,QAAM,mBAAmB,QAAQ,IAAI,CAAC,QAAQ,IAAI,EAAE;AACpD,QAAM,mBAAmB,IAAI,IAAI,gBAAgB;AACjD,QAAM,kBAAkB,oBAAI,IAAoB;AAChD,QAAM,iBAAiB,oBAAI,IAAoB;AAC/C,aAAW,OAAO,SAAS;AACzB,UAAM,WAAW,IAAI;AACrB,QAAI,CAAC,MAAM,QAAQ,QAAQ,EAAG;AAC9B,eAAW,WAAW,UAAU;AAC9B,UAAI,CAAC,WAAW,OAAO,QAAQ,OAAO,YAAY,CAAC,QAAQ,GAAI;AAC/D,YAAM,WAAW,OAAO,QAAQ,WAAW,YAAY,QAAQ,OAAO,SAAS,IAC3E,QAAQ,SACR,IAAI;AACR,sBAAgB,IAAI,QAAQ,IAAI,QAAQ;AACxC,YAAM,MAAM,QAAQ,GAAG,QAAQ,GAAG;AAClC,UAAI,MAAM,GAAG;AACX,cAAM,SAAS,QAAQ,GAAG,MAAM,GAAG,GAAG;AACtC,YAAI,CAAC,eAAe,IAAI,MAAM,EAAG,gBAAe,IAAI,QAAQ,QAAQ;AAAA,MACtE;AAAA,IACF;AAAA,EACF;AACA,SAAO,EAAE,kBAAkB,kBAAkB,iBAAiB,eAAe;AAC/E;AAEA,SAAS,cAAsC;AAC7C,MAAI;AACF,UAAM,UAAU,WAAW;AAC3B,QAAI,kBAAkB,qBAAqB,QAAS,QAAO;AAC3D,uBAAmB;AACnB,qBAAiB,cAAc,OAAO;AACtC,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,kBAAkB,WAA2B;AAC3D,QAAM,WAAW,YAAY;AAC7B,MAAI,UAAU;AACZ,UAAM,SAAS,SAAS,gBAAgB,IAAI,SAAS;AACrD,QAAI,OAAQ,QAAO;AACnB,QAAI,UAAU,SAAS,IAAI,GAAG;AAC5B,YAAM,SAAS,UAAU,MAAM,GAAG,EAAE;AACpC,YAAM,aAAa,SAAS,eAAe,IAAI,MAAM;AACrD,UAAI,WAAY,QAAO;AACvB,aAAO;AAAA,IACT;AAAA,EACF;AACA,QAAM,MAAM,UAAU,QAAQ,GAAG;AACjC,SAAO,QAAQ,KAAK,YAAY,UAAU,MAAM,GAAG,GAAG;AACxD;AAEO,SAAS,sBAAgC;AAC9C,QAAM,WAAW,YAAY;AAC7B,SAAO,WAAW,CAAC,GAAG,SAAS,gBAAgB,IAAI,CAAC;AACtD;AAWO,SAAS,6BAA6B,SAAsC;AACjF,QAAM,WAAW,YAAY;AAC7B,MAAI,CAAC,SAAU,QAAO,CAAC,GAAG,OAAO;AACjC,QAAM,EAAE,kBAAkB,kBAAkB,eAAe,IAAI;AAC/D,QAAM,SAAmB,CAAC;AAC1B,aAAW,SAAS,SAAS;AAC3B,QAAI,UAAU,KAAK;AACjB,iBAAW,MAAM,iBAAkB,QAAO,KAAK,GAAG,EAAE,IAAI;AACxD,iBAAW,CAAC,QAAQ,YAAY,KAAK,gBAAgB;AACnD,YAAI,CAAC,iBAAiB,IAAI,MAAM,KAAK,iBAAiB,IAAI,YAAY,GAAG;AACvE,iBAAO,KAAK,GAAG,MAAM,IAAI;AAAA,QAC3B;AAAA,MACF;AACA;AAAA,IACF;AACA,QAAI,iBAAiB,IAAI,kBAAkB,KAAK,CAAC,EAAG,QAAO,KAAK,KAAK;AAAA,EACvE;AACA,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.5.1-develop.
|
|
3
|
+
"version": "0.5.1-develop.2964.d5ac4a6ebb",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"@mikro-orm/core": "^7.0.10",
|
|
93
93
|
"@mikro-orm/decorators": "^7.0.10",
|
|
94
94
|
"@mikro-orm/postgresql": "^7.0.10",
|
|
95
|
-
"@open-mercato/cache": "0.5.1-develop.
|
|
95
|
+
"@open-mercato/cache": "0.5.1-develop.2964.d5ac4a6ebb",
|
|
96
96
|
"dotenv": "^17.4.2",
|
|
97
97
|
"rate-limiter-flexible": "^11.0.1",
|
|
98
98
|
"reflect-metadata": "^0.2.2",
|
|
@@ -17,11 +17,28 @@ describe('enabledModulesRegistry', () => {
|
|
|
17
17
|
jest.resetAllMocks()
|
|
18
18
|
})
|
|
19
19
|
|
|
20
|
-
it('derives the owning module from the feature id prefix', () => {
|
|
20
|
+
it('derives the owning module from the feature id prefix when the registry has no declarations', () => {
|
|
21
|
+
mockGetModules.mockReturnValue([{ id: 'auth' } as Module])
|
|
21
22
|
expect(getOwningModuleId('ai_assistant.view')).toBe('ai_assistant')
|
|
22
23
|
expect(getOwningModuleId('plain-feature')).toBe('plain-feature')
|
|
23
24
|
})
|
|
24
25
|
|
|
26
|
+
it('uses the declared module from the registry for off-convention feature ids (e.g. analytics.view)', () => {
|
|
27
|
+
mockGetModules.mockReturnValue([
|
|
28
|
+
{
|
|
29
|
+
id: 'dashboards',
|
|
30
|
+
features: [
|
|
31
|
+
{ id: 'dashboards.view', title: 'View dashboard', module: 'dashboards' },
|
|
32
|
+
{ id: 'analytics.view', title: 'View analytics widgets', module: 'dashboards' },
|
|
33
|
+
],
|
|
34
|
+
} as Module,
|
|
35
|
+
{ id: 'auth' } as Module,
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
expect(getOwningModuleId('analytics.view')).toBe('dashboards')
|
|
39
|
+
expect(getOwningModuleId('dashboards.view')).toBe('dashboards')
|
|
40
|
+
})
|
|
41
|
+
|
|
25
42
|
it('reads enabled module ids from the registered module list', () => {
|
|
26
43
|
mockGetModules.mockReturnValue([
|
|
27
44
|
{ id: 'auth' } as Module,
|
|
@@ -46,6 +63,28 @@ describe('enabledModulesRegistry', () => {
|
|
|
46
63
|
).toEqual(['auth.*', 'customer_accounts.view'])
|
|
47
64
|
})
|
|
48
65
|
|
|
66
|
+
it('keeps an off-convention grant when its declared owning module is enabled', () => {
|
|
67
|
+
mockGetModules.mockReturnValue([
|
|
68
|
+
{
|
|
69
|
+
id: 'dashboards',
|
|
70
|
+
features: [
|
|
71
|
+
{ id: 'analytics.view', title: 'View analytics widgets', module: 'dashboards' },
|
|
72
|
+
],
|
|
73
|
+
} as Module,
|
|
74
|
+
{ id: 'auth' } as Module,
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
expect(
|
|
78
|
+
filterGrantsByEnabledModules(['analytics.view', 'auth.users.view', 'unknown.feature']),
|
|
79
|
+
).toEqual(['analytics.view', 'auth.users.view'])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('drops an off-convention grant when its declared owning module is disabled', () => {
|
|
83
|
+
mockGetModules.mockReturnValue([{ id: 'auth' } as Module])
|
|
84
|
+
|
|
85
|
+
expect(filterGrantsByEnabledModules(['analytics.view'])).toEqual([])
|
|
86
|
+
})
|
|
87
|
+
|
|
49
88
|
it('expands the superadmin wildcard into enabled-module wildcards', () => {
|
|
50
89
|
mockGetModules.mockReturnValue([
|
|
51
90
|
{ id: 'auth' } as Module,
|
|
@@ -55,6 +94,25 @@ describe('enabledModulesRegistry', () => {
|
|
|
55
94
|
expect(filterGrantsByEnabledModules(['*'])).toEqual(['auth.*', 'customer_accounts.*'])
|
|
56
95
|
})
|
|
57
96
|
|
|
97
|
+
it('also expands the superadmin wildcard to off-convention prefixes whose owning module is enabled', () => {
|
|
98
|
+
mockGetModules.mockReturnValue([
|
|
99
|
+
{
|
|
100
|
+
id: 'dashboards',
|
|
101
|
+
features: [
|
|
102
|
+
{ id: 'dashboards.view', title: 'View dashboard', module: 'dashboards' },
|
|
103
|
+
{ id: 'analytics.view', title: 'View analytics widgets', module: 'dashboards' },
|
|
104
|
+
],
|
|
105
|
+
} as Module,
|
|
106
|
+
{ id: 'auth' } as Module,
|
|
107
|
+
])
|
|
108
|
+
|
|
109
|
+
expect(filterGrantsByEnabledModules(['*'])).toEqual([
|
|
110
|
+
'dashboards.*',
|
|
111
|
+
'auth.*',
|
|
112
|
+
'analytics.*',
|
|
113
|
+
])
|
|
114
|
+
})
|
|
115
|
+
|
|
58
116
|
it('falls back to the raw grant list when the module registry is unavailable', () => {
|
|
59
117
|
mockGetModules.mockImplementation(() => {
|
|
60
118
|
throw new Error('registry not initialized')
|
|
@@ -8,6 +8,15 @@
|
|
|
8
8
|
* whose owning module is not enabled — otherwise stale grants re-open the
|
|
9
9
|
* 404-class bug PR #1567 only partially fixed.
|
|
10
10
|
*
|
|
11
|
+
* Owning-module resolution:
|
|
12
|
+
* - Most features follow the convention so `id.split('.')[0]` matches the
|
|
13
|
+
* module id. For those, the prefix is correct.
|
|
14
|
+
* - A few features (e.g. `analytics.view`) deliberately use a different
|
|
15
|
+
* namespace from their owning module. For those, the registry's declared
|
|
16
|
+
* `module` field on the `Module.features` entry is authoritative. We
|
|
17
|
+
* consult it first and fall back to the prefix only when the feature is
|
|
18
|
+
* unknown to the registry.
|
|
19
|
+
*
|
|
11
20
|
* This helper is server-only: it reads the enabled module set from the
|
|
12
21
|
* bootstrapped module registry. The browser never imports it; instead,
|
|
13
22
|
* server code pre-filters `BackendChromePayload.grantedFeatures` so
|
|
@@ -15,42 +24,100 @@
|
|
|
15
24
|
*/
|
|
16
25
|
|
|
17
26
|
import { getModules } from '../lib/modules/registry'
|
|
27
|
+
import type { Module } from '../modules/registry'
|
|
18
28
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
29
|
+
type FeatureRegistry = {
|
|
30
|
+
enabledModuleIds: string[]
|
|
31
|
+
enabledModuleSet: Set<string>
|
|
32
|
+
featureToModule: Map<string, string>
|
|
33
|
+
prefixToModule: Map<string, string>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let cachedRegistry: FeatureRegistry | null = null
|
|
37
|
+
let cachedModulesRef: readonly Module[] | null = null
|
|
38
|
+
|
|
39
|
+
function buildRegistry(modules: readonly Module[]): FeatureRegistry {
|
|
40
|
+
const enabledModuleIds = modules.map((mod) => mod.id)
|
|
41
|
+
const enabledModuleSet = new Set(enabledModuleIds)
|
|
42
|
+
const featureToModule = new Map<string, string>()
|
|
43
|
+
const prefixToModule = new Map<string, string>()
|
|
44
|
+
for (const mod of modules) {
|
|
45
|
+
const features = mod.features
|
|
46
|
+
if (!Array.isArray(features)) continue
|
|
47
|
+
for (const feature of features) {
|
|
48
|
+
if (!feature || typeof feature.id !== 'string' || !feature.id) continue
|
|
49
|
+
const declared = typeof feature.module === 'string' && feature.module.length > 0
|
|
50
|
+
? feature.module
|
|
51
|
+
: mod.id
|
|
52
|
+
featureToModule.set(feature.id, declared)
|
|
53
|
+
const dot = feature.id.indexOf('.')
|
|
54
|
+
if (dot > 0) {
|
|
55
|
+
const prefix = feature.id.slice(0, dot)
|
|
56
|
+
if (!prefixToModule.has(prefix)) prefixToModule.set(prefix, declared)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { enabledModuleIds, enabledModuleSet, featureToModule, prefixToModule }
|
|
22
61
|
}
|
|
23
62
|
|
|
24
|
-
function
|
|
63
|
+
function getRegistry(): FeatureRegistry | null {
|
|
25
64
|
try {
|
|
26
|
-
|
|
65
|
+
const modules = getModules() as readonly Module[]
|
|
66
|
+
if (cachedRegistry && cachedModulesRef === modules) return cachedRegistry
|
|
67
|
+
cachedModulesRef = modules
|
|
68
|
+
cachedRegistry = buildRegistry(modules)
|
|
69
|
+
return cachedRegistry
|
|
27
70
|
} catch {
|
|
28
71
|
return null
|
|
29
72
|
}
|
|
30
73
|
}
|
|
31
74
|
|
|
75
|
+
export function getOwningModuleId(featureId: string): string {
|
|
76
|
+
const registry = getRegistry()
|
|
77
|
+
if (registry) {
|
|
78
|
+
const direct = registry.featureToModule.get(featureId)
|
|
79
|
+
if (direct) return direct
|
|
80
|
+
if (featureId.endsWith('.*')) {
|
|
81
|
+
const prefix = featureId.slice(0, -2)
|
|
82
|
+
const fromPrefix = registry.prefixToModule.get(prefix)
|
|
83
|
+
if (fromPrefix) return fromPrefix
|
|
84
|
+
return prefix
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const dot = featureId.indexOf('.')
|
|
88
|
+
return dot === -1 ? featureId : featureId.slice(0, dot)
|
|
89
|
+
}
|
|
90
|
+
|
|
32
91
|
export function getEnabledModuleIds(): string[] {
|
|
33
|
-
|
|
92
|
+
const registry = getRegistry()
|
|
93
|
+
return registry ? [...registry.enabledModuleIds] : []
|
|
34
94
|
}
|
|
35
95
|
|
|
36
96
|
/**
|
|
37
97
|
* Filters a raw granted-features list down to the grants whose owning
|
|
38
98
|
* module is currently enabled. Expands `*` (superadmin) into one wildcard
|
|
39
99
|
* per enabled module so the result is still safe to feed into a pure
|
|
40
|
-
* `matchFeature` check
|
|
41
|
-
*
|
|
100
|
+
* `matchFeature` check, plus one wildcard per off-convention feature
|
|
101
|
+
* prefix (e.g. `analytics.*`) whose declared owning module is enabled.
|
|
102
|
+
* If the module registry is not populated (tests, CLI), returns the input
|
|
103
|
+
* unchanged — preserves legacy behavior.
|
|
42
104
|
*/
|
|
43
105
|
export function filterGrantsByEnabledModules(granted: readonly string[]): string[] {
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
const
|
|
106
|
+
const registry = getRegistry()
|
|
107
|
+
if (!registry) return [...granted]
|
|
108
|
+
const { enabledModuleIds, enabledModuleSet, prefixToModule } = registry
|
|
47
109
|
const result: string[] = []
|
|
48
110
|
for (const grant of granted) {
|
|
49
111
|
if (grant === '*') {
|
|
50
|
-
for (const id of
|
|
112
|
+
for (const id of enabledModuleIds) result.push(`${id}.*`)
|
|
113
|
+
for (const [prefix, owningModule] of prefixToModule) {
|
|
114
|
+
if (!enabledModuleSet.has(prefix) && enabledModuleSet.has(owningModule)) {
|
|
115
|
+
result.push(`${prefix}.*`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
51
118
|
continue
|
|
52
119
|
}
|
|
53
|
-
if (
|
|
120
|
+
if (enabledModuleSet.has(getOwningModuleId(grant))) result.push(grant)
|
|
54
121
|
}
|
|
55
122
|
return result
|
|
56
123
|
}
|