@open-mercato/shared 0.5.1-develop.2954.610bab2d08 → 0.5.1-develop.2965.38737e655d

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,4 +1,4 @@
1
- const APP_VERSION = "0.5.1-develop.2954.610bab2d08";
1
+ const APP_VERSION = "0.5.1-develop.2965.38737e655d";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -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.2954.610bab2d08'\nexport const appVersion = APP_VERSION\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.5.1-develop.2965.38737e655d'\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
- function getOwningModuleId(featureId) {
3
- const dot = featureId.indexOf(".");
4
- return dot === -1 ? featureId : featureId.slice(0, dot);
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 safeGetEnabledModuleIds() {
25
+ function getRegistry() {
7
26
  try {
8
- return getModules().map((mod) => mod.id);
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
- return safeGetEnabledModuleIds() ?? [];
52
+ const registry = getRegistry();
53
+ return registry ? [...registry.enabledModuleIds] : [];
15
54
  }
16
55
  function filterGrantsByEnabledModules(granted) {
17
- const enabledIds = safeGetEnabledModuleIds();
18
- if (enabledIds === null) return [...granted];
19
- const enabledSet = new Set(enabledIds);
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 enabledIds) result.push(`${id}.*`);
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 (enabledSet.has(getOwningModuleId(grant))) result.push(grant);
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\nexport function getOwningModuleId(featureId: string): string {\n const dot = featureId.indexOf('.')\n return dot === -1 ? featureId : featureId.slice(0, dot)\n}\n\nfunction safeGetEnabledModuleIds(): string[] | null {\n try {\n return getModules().map((mod) => mod.id)\n } catch {\n return null\n }\n}\n\nexport function getEnabledModuleIds(): string[] {\n return safeGetEnabledModuleIds() ?? []\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. If the module registry is not populated (tests,\n * CLI), returns the input unchanged \u2014 preserves legacy behavior.\n */\nexport function filterGrantsByEnabledModules(granted: readonly string[]): string[] {\n const enabledIds = safeGetEnabledModuleIds()\n if (enabledIds === null) return [...granted]\n const enabledSet = new Set(enabledIds)\n const result: string[] = []\n for (const grant of granted) {\n if (grant === '*') {\n for (const id of enabledIds) result.push(`${id}.*`)\n continue\n }\n if (enabledSet.has(getOwningModuleId(grant))) result.push(grant)\n }\n return result\n}\n"],
5
- "mappings": "AAgBA,SAAS,kBAAkB;AAEpB,SAAS,kBAAkB,WAA2B;AAC3D,QAAM,MAAM,UAAU,QAAQ,GAAG;AACjC,SAAO,QAAQ,KAAK,YAAY,UAAU,MAAM,GAAG,GAAG;AACxD;AAEA,SAAS,0BAA2C;AAClD,MAAI;AACF,WAAO,WAAW,EAAE,IAAI,CAAC,QAAQ,IAAI,EAAE;AAAA,EACzC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,sBAAgC;AAC9C,SAAO,wBAAwB,KAAK,CAAC;AACvC;AASO,SAAS,6BAA6B,SAAsC;AACjF,QAAM,aAAa,wBAAwB;AAC3C,MAAI,eAAe,KAAM,QAAO,CAAC,GAAG,OAAO;AAC3C,QAAM,aAAa,IAAI,IAAI,UAAU;AACrC,QAAM,SAAmB,CAAC;AAC1B,aAAW,SAAS,SAAS;AAC3B,QAAI,UAAU,KAAK;AACjB,iBAAW,MAAM,WAAY,QAAO,KAAK,GAAG,EAAE,IAAI;AAClD;AAAA,IACF;AACA,QAAI,WAAW,IAAI,kBAAkB,KAAK,CAAC,EAAG,QAAO,KAAK,KAAK;AAAA,EACjE;AACA,SAAO;AACT;",
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.2954.610bab2d08",
3
+ "version": "0.5.1-develop.2965.38737e655d",
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.2954.610bab2d08",
95
+ "@open-mercato/cache": "0.5.1-develop.2965.38737e655d",
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
- export function getOwningModuleId(featureId: string): string {
20
- const dot = featureId.indexOf('.')
21
- return dot === -1 ? featureId : featureId.slice(0, dot)
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 safeGetEnabledModuleIds(): string[] | null {
63
+ function getRegistry(): FeatureRegistry | null {
25
64
  try {
26
- return getModules().map((mod) => mod.id)
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
- return safeGetEnabledModuleIds() ?? []
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. If the module registry is not populated (tests,
41
- * CLI), returns the input unchanged preserves legacy behavior.
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 enabledIds = safeGetEnabledModuleIds()
45
- if (enabledIds === null) return [...granted]
46
- const enabledSet = new Set(enabledIds)
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 enabledIds) result.push(`${id}.*`)
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 (enabledSet.has(getOwningModuleId(grant))) result.push(grant)
120
+ if (enabledModuleSet.has(getOwningModuleId(grant))) result.push(grant)
54
121
  }
55
122
  return result
56
123
  }