@open-mercato/shared 0.6.4-develop.3996.1.430e257cfc → 0.6.4-develop.4000.1.450e315cec

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,2 +1,2 @@
1
- [build:shared] found 211 entry points
1
+ [build:shared] found 212 entry points
2
2
  [build:shared] built successfully
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.4-develop.3996.1.430e257cfc";
1
+ const APP_VERSION = "0.6.4-develop.4000.1.450e315cec";
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.6.4-develop.3996.1.430e257cfc'\nexport const appVersion = APP_VERSION\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.4-develop.4000.1.450e315cec'\nexport const appVersion = APP_VERSION\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,106 @@
1
+ import { hasFeature } from "./features.js";
2
+ const EMPTY_DIAGNOSTICS = Object.freeze({
3
+ missingDependencies: Object.freeze([]),
4
+ orphanedDependents: Object.freeze([]),
5
+ unknownReferences: Object.freeze([])
6
+ });
7
+ function normalizeGranted(granted) {
8
+ if (!Array.isArray(granted)) return [];
9
+ const set = /* @__PURE__ */ new Set();
10
+ for (const entry of granted) {
11
+ if (typeof entry !== "string") continue;
12
+ const trimmed = entry.trim();
13
+ if (!trimmed) continue;
14
+ set.add(trimmed);
15
+ }
16
+ return Array.from(set);
17
+ }
18
+ function indexCatalog(catalog) {
19
+ const map = /* @__PURE__ */ new Map();
20
+ for (const descriptor of catalog) {
21
+ if (!descriptor || typeof descriptor.id !== "string") continue;
22
+ const id = descriptor.id.trim();
23
+ if (!id) continue;
24
+ if (!map.has(id)) map.set(id, descriptor);
25
+ }
26
+ return map;
27
+ }
28
+ function resolveAclDependencyDiagnostics(granted, catalog) {
29
+ if (!Array.isArray(catalog) || catalog.length === 0) return EMPTY_DIAGNOSTICS;
30
+ const grantedList = normalizeGranted(granted);
31
+ if (grantedList.includes("*")) return EMPTY_DIAGNOSTICS;
32
+ const catalogIndex = indexCatalog(catalog);
33
+ const missingDependencies = [];
34
+ const unknownReferences = [];
35
+ const dependentsByDependency = /* @__PURE__ */ new Map();
36
+ for (const descriptor of catalog) {
37
+ if (!descriptor || typeof descriptor.id !== "string") continue;
38
+ const featureId = descriptor.id.trim();
39
+ if (!featureId) continue;
40
+ const deps = Array.isArray(descriptor.dependsOn) ? descriptor.dependsOn : [];
41
+ if (deps.length === 0) continue;
42
+ if (!hasFeature(grantedList, featureId)) continue;
43
+ const missing = [];
44
+ const unknown = [];
45
+ for (const rawDep of deps) {
46
+ if (typeof rawDep !== "string") continue;
47
+ const dep = rawDep.trim();
48
+ if (!dep) continue;
49
+ const isRegistered = catalogIndex.has(dep) || dep.endsWith(".*") || dep === "*";
50
+ if (!isRegistered) {
51
+ unknown.push(dep);
52
+ continue;
53
+ }
54
+ if (!hasFeature(grantedList, dep)) {
55
+ missing.push(dep);
56
+ const bucket = dependentsByDependency.get(dep) ?? /* @__PURE__ */ new Set();
57
+ bucket.add(featureId);
58
+ dependentsByDependency.set(dep, bucket);
59
+ }
60
+ }
61
+ if (missing.length > 0) {
62
+ missingDependencies.push({ feature: featureId, missing: dedupe(missing) });
63
+ }
64
+ if (unknown.length > 0) {
65
+ unknownReferences.push({ feature: featureId, missing: dedupe(unknown) });
66
+ }
67
+ }
68
+ const orphanedDependents = [];
69
+ for (const [dep, dependents] of dependentsByDependency) {
70
+ const known = catalogIndex.has(dep);
71
+ if (!known) continue;
72
+ orphanedDependents.push({
73
+ dependency: dep,
74
+ dependents: Array.from(dependents).sort((a, b) => a.localeCompare(b))
75
+ });
76
+ }
77
+ orphanedDependents.sort((a, b) => a.dependency.localeCompare(b.dependency));
78
+ return {
79
+ missingDependencies,
80
+ orphanedDependents,
81
+ unknownReferences: unknownReferences.sort((a, b) => a.feature.localeCompare(b.feature))
82
+ };
83
+ }
84
+ function dedupe(values) {
85
+ return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
86
+ }
87
+ function applyAddMissingDependency(granted, dependency) {
88
+ if (!dependency || !dependency.trim()) return [...granted];
89
+ if (granted.includes(dependency)) return [...granted];
90
+ return [...granted, dependency];
91
+ }
92
+ function applyRemoveDependents(granted, dependents) {
93
+ if (!dependents.length) return [...granted];
94
+ const dropSet = new Set(dependents);
95
+ return granted.filter((feature) => !dropSet.has(feature));
96
+ }
97
+ function applyRestoreDependency(granted, dependency) {
98
+ return applyAddMissingDependency(granted, dependency);
99
+ }
100
+ export {
101
+ applyAddMissingDependency,
102
+ applyRemoveDependents,
103
+ applyRestoreDependency,
104
+ resolveAclDependencyDiagnostics
105
+ };
106
+ //# sourceMappingURL=aclDependencies.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/security/aclDependencies.ts"],
4
+ "sourcesContent": ["import { hasFeature } from './features'\n\nexport type FeatureDescriptor = {\n id: string\n title?: string\n module?: string\n dependsOn?: readonly string[]\n}\n\nexport type MissingDependency = {\n feature: string\n missing: readonly string[]\n}\n\nexport type OrphanedDependent = {\n dependency: string\n dependents: readonly string[]\n}\n\nexport type UnknownReference = {\n feature: string\n missing: readonly string[]\n}\n\nexport type AclDependencyDiagnostics = {\n missingDependencies: readonly MissingDependency[]\n orphanedDependents: readonly OrphanedDependent[]\n unknownReferences: readonly UnknownReference[]\n}\n\nconst EMPTY_DIAGNOSTICS: AclDependencyDiagnostics = Object.freeze({\n missingDependencies: Object.freeze([]),\n orphanedDependents: Object.freeze([]),\n unknownReferences: Object.freeze([]),\n})\n\nfunction normalizeGranted(granted: readonly string[] | undefined): string[] {\n if (!Array.isArray(granted)) return []\n const set = new Set<string>()\n for (const entry of granted) {\n if (typeof entry !== 'string') continue\n const trimmed = entry.trim()\n if (!trimmed) continue\n set.add(trimmed)\n }\n return Array.from(set)\n}\n\nfunction indexCatalog(catalog: readonly FeatureDescriptor[]): Map<string, FeatureDescriptor> {\n const map = new Map<string, FeatureDescriptor>()\n for (const descriptor of catalog) {\n if (!descriptor || typeof descriptor.id !== 'string') continue\n const id = descriptor.id.trim()\n if (!id) continue\n if (!map.has(id)) map.set(id, descriptor)\n }\n return map\n}\n\nexport function resolveAclDependencyDiagnostics(\n granted: readonly string[] | undefined,\n catalog: readonly FeatureDescriptor[] | undefined,\n): AclDependencyDiagnostics {\n if (!Array.isArray(catalog) || catalog.length === 0) return EMPTY_DIAGNOSTICS\n const grantedList = normalizeGranted(granted)\n if (grantedList.includes('*')) return EMPTY_DIAGNOSTICS\n\n const catalogIndex = indexCatalog(catalog)\n\n const missingDependencies: MissingDependency[] = []\n const unknownReferences: UnknownReference[] = []\n const dependentsByDependency = new Map<string, Set<string>>()\n\n for (const descriptor of catalog) {\n if (!descriptor || typeof descriptor.id !== 'string') continue\n const featureId = descriptor.id.trim()\n if (!featureId) continue\n const deps = Array.isArray(descriptor.dependsOn) ? descriptor.dependsOn : []\n if (deps.length === 0) continue\n\n if (!hasFeature(grantedList, featureId)) continue\n\n const missing: string[] = []\n const unknown: string[] = []\n for (const rawDep of deps) {\n if (typeof rawDep !== 'string') continue\n const dep = rawDep.trim()\n if (!dep) continue\n const isRegistered = catalogIndex.has(dep) || dep.endsWith('.*') || dep === '*'\n if (!isRegistered) {\n unknown.push(dep)\n continue\n }\n if (!hasFeature(grantedList, dep)) {\n missing.push(dep)\n const bucket = dependentsByDependency.get(dep) ?? new Set<string>()\n bucket.add(featureId)\n dependentsByDependency.set(dep, bucket)\n }\n }\n\n if (missing.length > 0) {\n missingDependencies.push({ feature: featureId, missing: dedupe(missing) })\n }\n if (unknown.length > 0) {\n unknownReferences.push({ feature: featureId, missing: dedupe(unknown) })\n }\n }\n\n const orphanedDependents: OrphanedDependent[] = []\n for (const [dep, dependents] of dependentsByDependency) {\n const known = catalogIndex.has(dep)\n if (!known) continue\n orphanedDependents.push({\n dependency: dep,\n dependents: Array.from(dependents).sort((a, b) => a.localeCompare(b)),\n })\n }\n orphanedDependents.sort((a, b) => a.dependency.localeCompare(b.dependency))\n\n return {\n missingDependencies,\n orphanedDependents,\n unknownReferences: unknownReferences.sort((a, b) => a.feature.localeCompare(b.feature)),\n }\n}\n\nfunction dedupe(values: readonly string[]): string[] {\n return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b))\n}\n\nexport function applyAddMissingDependency(\n granted: readonly string[],\n dependency: string,\n): string[] {\n if (!dependency || !dependency.trim()) return [...granted]\n if (granted.includes(dependency)) return [...granted]\n return [...granted, dependency]\n}\n\nexport function applyRemoveDependents(\n granted: readonly string[],\n dependents: readonly string[],\n): string[] {\n if (!dependents.length) return [...granted]\n const dropSet = new Set(dependents)\n return granted.filter((feature) => !dropSet.has(feature))\n}\n\nexport function applyRestoreDependency(\n granted: readonly string[],\n dependency: string,\n): string[] {\n return applyAddMissingDependency(granted, dependency)\n}\n"],
5
+ "mappings": "AAAA,SAAS,kBAAkB;AA8B3B,MAAM,oBAA8C,OAAO,OAAO;AAAA,EAChE,qBAAqB,OAAO,OAAO,CAAC,CAAC;AAAA,EACrC,oBAAoB,OAAO,OAAO,CAAC,CAAC;AAAA,EACpC,mBAAmB,OAAO,OAAO,CAAC,CAAC;AACrC,CAAC;AAED,SAAS,iBAAiB,SAAkD;AAC1E,MAAI,CAAC,MAAM,QAAQ,OAAO,EAAG,QAAO,CAAC;AACrC,QAAM,MAAM,oBAAI,IAAY;AAC5B,aAAW,SAAS,SAAS;AAC3B,QAAI,OAAO,UAAU,SAAU;AAC/B,UAAM,UAAU,MAAM,KAAK;AAC3B,QAAI,CAAC,QAAS;AACd,QAAI,IAAI,OAAO;AAAA,EACjB;AACA,SAAO,MAAM,KAAK,GAAG;AACvB;AAEA,SAAS,aAAa,SAAuE;AAC3F,QAAM,MAAM,oBAAI,IAA+B;AAC/C,aAAW,cAAc,SAAS;AAChC,QAAI,CAAC,cAAc,OAAO,WAAW,OAAO,SAAU;AACtD,UAAM,KAAK,WAAW,GAAG,KAAK;AAC9B,QAAI,CAAC,GAAI;AACT,QAAI,CAAC,IAAI,IAAI,EAAE,EAAG,KAAI,IAAI,IAAI,UAAU;AAAA,EAC1C;AACA,SAAO;AACT;AAEO,SAAS,gCACd,SACA,SAC0B;AAC1B,MAAI,CAAC,MAAM,QAAQ,OAAO,KAAK,QAAQ,WAAW,EAAG,QAAO;AAC5D,QAAM,cAAc,iBAAiB,OAAO;AAC5C,MAAI,YAAY,SAAS,GAAG,EAAG,QAAO;AAEtC,QAAM,eAAe,aAAa,OAAO;AAEzC,QAAM,sBAA2C,CAAC;AAClD,QAAM,oBAAwC,CAAC;AAC/C,QAAM,yBAAyB,oBAAI,IAAyB;AAE5D,aAAW,cAAc,SAAS;AAChC,QAAI,CAAC,cAAc,OAAO,WAAW,OAAO,SAAU;AACtD,UAAM,YAAY,WAAW,GAAG,KAAK;AACrC,QAAI,CAAC,UAAW;AAChB,UAAM,OAAO,MAAM,QAAQ,WAAW,SAAS,IAAI,WAAW,YAAY,CAAC;AAC3E,QAAI,KAAK,WAAW,EAAG;AAEvB,QAAI,CAAC,WAAW,aAAa,SAAS,EAAG;AAEzC,UAAM,UAAoB,CAAC;AAC3B,UAAM,UAAoB,CAAC;AAC3B,eAAW,UAAU,MAAM;AACzB,UAAI,OAAO,WAAW,SAAU;AAChC,YAAM,MAAM,OAAO,KAAK;AACxB,UAAI,CAAC,IAAK;AACV,YAAM,eAAe,aAAa,IAAI,GAAG,KAAK,IAAI,SAAS,IAAI,KAAK,QAAQ;AAC5E,UAAI,CAAC,cAAc;AACjB,gBAAQ,KAAK,GAAG;AAChB;AAAA,MACF;AACA,UAAI,CAAC,WAAW,aAAa,GAAG,GAAG;AACjC,gBAAQ,KAAK,GAAG;AAChB,cAAM,SAAS,uBAAuB,IAAI,GAAG,KAAK,oBAAI,IAAY;AAClE,eAAO,IAAI,SAAS;AACpB,+BAAuB,IAAI,KAAK,MAAM;AAAA,MACxC;AAAA,IACF;AAEA,QAAI,QAAQ,SAAS,GAAG;AACtB,0BAAoB,KAAK,EAAE,SAAS,WAAW,SAAS,OAAO,OAAO,EAAE,CAAC;AAAA,IAC3E;AACA,QAAI,QAAQ,SAAS,GAAG;AACtB,wBAAkB,KAAK,EAAE,SAAS,WAAW,SAAS,OAAO,OAAO,EAAE,CAAC;AAAA,IACzE;AAAA,EACF;AAEA,QAAM,qBAA0C,CAAC;AACjD,aAAW,CAAC,KAAK,UAAU,KAAK,wBAAwB;AACtD,UAAM,QAAQ,aAAa,IAAI,GAAG;AAClC,QAAI,CAAC,MAAO;AACZ,uBAAmB,KAAK;AAAA,MACtB,YAAY;AAAA,MACZ,YAAY,MAAM,KAAK,UAAU,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,cAAc,CAAC,CAAC;AAAA,IACtE,CAAC;AAAA,EACH;AACA,qBAAmB,KAAK,CAAC,GAAG,MAAM,EAAE,WAAW,cAAc,EAAE,UAAU,CAAC;AAE1E,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,mBAAmB,kBAAkB,KAAK,CAAC,GAAG,MAAM,EAAE,QAAQ,cAAc,EAAE,OAAO,CAAC;AAAA,EACxF;AACF;AAEA,SAAS,OAAO,QAAqC;AACnD,SAAO,MAAM,KAAK,IAAI,IAAI,MAAM,CAAC,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,cAAc,CAAC,CAAC;AACtE;AAEO,SAAS,0BACd,SACA,YACU;AACV,MAAI,CAAC,cAAc,CAAC,WAAW,KAAK,EAAG,QAAO,CAAC,GAAG,OAAO;AACzD,MAAI,QAAQ,SAAS,UAAU,EAAG,QAAO,CAAC,GAAG,OAAO;AACpD,SAAO,CAAC,GAAG,SAAS,UAAU;AAChC;AAEO,SAAS,sBACd,SACA,YACU;AACV,MAAI,CAAC,WAAW,OAAQ,QAAO,CAAC,GAAG,OAAO;AAC1C,QAAM,UAAU,IAAI,IAAI,UAAU;AAClC,SAAO,QAAQ,OAAO,CAAC,YAAY,CAAC,QAAQ,IAAI,OAAO,CAAC;AAC1D;AAEO,SAAS,uBACd,SACA,YACU;AACV,SAAO,0BAA0B,SAAS,UAAU;AACtD;",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.6.4-develop.3996.1.430e257cfc",
3
+ "version": "0.6.4-develop.4000.1.450e315cec",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -92,7 +92,7 @@
92
92
  "@mikro-orm/core": "^7.1.1",
93
93
  "@mikro-orm/decorators": "^7.1.1",
94
94
  "@mikro-orm/postgresql": "^7.1.1",
95
- "@open-mercato/cache": "0.6.4-develop.3996.1.430e257cfc",
95
+ "@open-mercato/cache": "0.6.4-develop.4000.1.450e315cec",
96
96
  "dotenv": "^17.4.2",
97
97
  "rate-limiter-flexible": "^11.1.0",
98
98
  "re2js": "2.8.3",
@@ -0,0 +1,177 @@
1
+ import {
2
+ applyAddMissingDependency,
3
+ applyRemoveDependents,
4
+ applyRestoreDependency,
5
+ resolveAclDependencyDiagnostics,
6
+ type FeatureDescriptor,
7
+ } from '../aclDependencies'
8
+
9
+ const catalog: FeatureDescriptor[] = [
10
+ { id: 'customers.people.view', title: 'View people', module: 'customers' },
11
+ {
12
+ id: 'customers.people.manage',
13
+ title: 'Manage people',
14
+ module: 'customers',
15
+ dependsOn: ['customers.people.view'],
16
+ },
17
+ { id: 'customers.deals.view', title: 'View deals', module: 'customers', dependsOn: ['customers.people.view'] },
18
+ { id: 'customers.deals.manage', title: 'Manage deals', module: 'customers', dependsOn: ['customers.deals.view'] },
19
+ { id: 'customers.activities.view', title: 'View activities', module: 'customers' },
20
+ {
21
+ id: 'customers.widgets.todos',
22
+ title: 'Todos widget',
23
+ module: 'customers',
24
+ dependsOn: ['customers.activities.view'],
25
+ },
26
+ {
27
+ id: 'sales.orders.view',
28
+ title: 'View orders',
29
+ module: 'sales',
30
+ dependsOn: ['customers.people.view', 'sales.channels.view-doesnt-exist'],
31
+ },
32
+ ]
33
+
34
+ describe('resolveAclDependencyDiagnostics', () => {
35
+ it('returns empty diagnostics when granted is empty', () => {
36
+ const result = resolveAclDependencyDiagnostics([], catalog)
37
+ expect(result.missingDependencies).toEqual([])
38
+ expect(result.orphanedDependents).toEqual([])
39
+ expect(result.unknownReferences).toEqual([])
40
+ })
41
+
42
+ it('returns empty diagnostics when granted contains global wildcard', () => {
43
+ const result = resolveAclDependencyDiagnostics(['*'], catalog)
44
+ expect(result.missingDependencies).toEqual([])
45
+ expect(result.orphanedDependents).toEqual([])
46
+ expect(result.unknownReferences).toEqual([])
47
+ })
48
+
49
+ it('returns empty diagnostics when all granted features have their deps satisfied', () => {
50
+ const result = resolveAclDependencyDiagnostics(
51
+ ['customers.people.view', 'customers.people.manage', 'customers.deals.view', 'customers.deals.manage'],
52
+ catalog,
53
+ )
54
+ expect(result.missingDependencies).toEqual([])
55
+ expect(result.orphanedDependents).toEqual([])
56
+ })
57
+
58
+ it('flags a granted feature whose declared dependency is missing', () => {
59
+ const result = resolveAclDependencyDiagnostics(['customers.people.manage'], catalog)
60
+ expect(result.missingDependencies).toEqual([
61
+ { feature: 'customers.people.manage', missing: ['customers.people.view'] },
62
+ ])
63
+ })
64
+
65
+ it('treats module wildcards as satisfying matching deps', () => {
66
+ const result = resolveAclDependencyDiagnostics(['customers.*', 'customers.people.manage'], catalog)
67
+ expect(result.missingDependencies).toEqual([])
68
+ })
69
+
70
+ it('reports orphaned dependents when a parent is not granted but children are', () => {
71
+ const result = resolveAclDependencyDiagnostics(['customers.people.manage', 'customers.deals.view'], catalog)
72
+ // people.manage depends on people.view (not granted) → orphan parent: customers.people.view
73
+ // deals.view depends on people.view → also orphan parent: customers.people.view
74
+ expect(result.orphanedDependents).toEqual([
75
+ {
76
+ dependency: 'customers.people.view',
77
+ dependents: ['customers.deals.view', 'customers.people.manage'],
78
+ },
79
+ ])
80
+ })
81
+
82
+ it('flags unknown references for deps that do not resolve to a registered feature', () => {
83
+ const result = resolveAclDependencyDiagnostics(['sales.orders.view'], catalog)
84
+ expect(result.unknownReferences).toEqual([
85
+ {
86
+ feature: 'sales.orders.view',
87
+ missing: ['sales.channels.view-doesnt-exist'],
88
+ },
89
+ ])
90
+ })
91
+
92
+ it('skips wildcard dep targets from unknown-reference checks', () => {
93
+ const wildcardCatalog: FeatureDescriptor[] = [
94
+ { id: 'compose.feature', dependsOn: ['something.*'] },
95
+ ]
96
+ const result = resolveAclDependencyDiagnostics(['compose.feature'], wildcardCatalog)
97
+ expect(result.unknownReferences).toEqual([])
98
+ expect(result.missingDependencies).toEqual([
99
+ { feature: 'compose.feature', missing: ['something.*'] },
100
+ ])
101
+ })
102
+
103
+ it('does not double-count when the same dep is missing from multiple granted features', () => {
104
+ const result = resolveAclDependencyDiagnostics(
105
+ ['customers.deals.view', 'customers.deals.manage'],
106
+ catalog,
107
+ )
108
+ // both depend (directly or transitively) on customers.people.view
109
+ expect(result.orphanedDependents).toEqual([
110
+ {
111
+ dependency: 'customers.people.view',
112
+ dependents: ['customers.deals.view'],
113
+ },
114
+ ])
115
+ // deals.manage depends on deals.view which IS granted → no missing
116
+ expect(result.missingDependencies).toEqual([
117
+ { feature: 'customers.deals.view', missing: ['customers.people.view'] },
118
+ ])
119
+ })
120
+
121
+ it('detects widget dependency violation when widget granted without underlying view', () => {
122
+ const result = resolveAclDependencyDiagnostics(['customers.widgets.todos'], catalog)
123
+ expect(result.missingDependencies).toEqual([
124
+ { feature: 'customers.widgets.todos', missing: ['customers.activities.view'] },
125
+ ])
126
+ })
127
+
128
+ it('ignores deps that are dropped/empty strings', () => {
129
+ const weirdCatalog: FeatureDescriptor[] = [
130
+ { id: 'a', dependsOn: ['', ' ', 'b'] },
131
+ { id: 'b' },
132
+ ]
133
+ const result = resolveAclDependencyDiagnostics(['a'], weirdCatalog)
134
+ expect(result.missingDependencies).toEqual([{ feature: 'a', missing: ['b'] }])
135
+ })
136
+
137
+ it('handles repeated entries in granted (dedupe via Set)', () => {
138
+ const result = resolveAclDependencyDiagnostics(
139
+ ['customers.people.manage', 'customers.people.manage'],
140
+ catalog,
141
+ )
142
+ expect(result.missingDependencies).toEqual([
143
+ { feature: 'customers.people.manage', missing: ['customers.people.view'] },
144
+ ])
145
+ })
146
+ })
147
+
148
+ describe('applyAddMissingDependency', () => {
149
+ it('appends the dependency if not already present', () => {
150
+ expect(applyAddMissingDependency(['a'], 'b')).toEqual(['a', 'b'])
151
+ })
152
+ it('is a no-op when already present', () => {
153
+ expect(applyAddMissingDependency(['a', 'b'], 'b')).toEqual(['a', 'b'])
154
+ })
155
+ it('skips empty strings', () => {
156
+ expect(applyAddMissingDependency(['a'], '')).toEqual(['a'])
157
+ expect(applyAddMissingDependency(['a'], ' ')).toEqual(['a'])
158
+ })
159
+ })
160
+
161
+ describe('applyRemoveDependents', () => {
162
+ it('removes listed features from granted', () => {
163
+ expect(applyRemoveDependents(['a', 'b', 'c'], ['b'])).toEqual(['a', 'c'])
164
+ })
165
+ it('removes multiple', () => {
166
+ expect(applyRemoveDependents(['a', 'b', 'c'], ['a', 'c'])).toEqual(['b'])
167
+ })
168
+ it('is a no-op for empty list', () => {
169
+ expect(applyRemoveDependents(['a', 'b'], [])).toEqual(['a', 'b'])
170
+ })
171
+ })
172
+
173
+ describe('applyRestoreDependency', () => {
174
+ it('adds the dependency back (alias of applyAddMissingDependency)', () => {
175
+ expect(applyRestoreDependency(['a'], 'b')).toEqual(['a', 'b'])
176
+ })
177
+ })
@@ -0,0 +1,155 @@
1
+ import { hasFeature } from './features'
2
+
3
+ export type FeatureDescriptor = {
4
+ id: string
5
+ title?: string
6
+ module?: string
7
+ dependsOn?: readonly string[]
8
+ }
9
+
10
+ export type MissingDependency = {
11
+ feature: string
12
+ missing: readonly string[]
13
+ }
14
+
15
+ export type OrphanedDependent = {
16
+ dependency: string
17
+ dependents: readonly string[]
18
+ }
19
+
20
+ export type UnknownReference = {
21
+ feature: string
22
+ missing: readonly string[]
23
+ }
24
+
25
+ export type AclDependencyDiagnostics = {
26
+ missingDependencies: readonly MissingDependency[]
27
+ orphanedDependents: readonly OrphanedDependent[]
28
+ unknownReferences: readonly UnknownReference[]
29
+ }
30
+
31
+ const EMPTY_DIAGNOSTICS: AclDependencyDiagnostics = Object.freeze({
32
+ missingDependencies: Object.freeze([]),
33
+ orphanedDependents: Object.freeze([]),
34
+ unknownReferences: Object.freeze([]),
35
+ })
36
+
37
+ function normalizeGranted(granted: readonly string[] | undefined): string[] {
38
+ if (!Array.isArray(granted)) return []
39
+ const set = new Set<string>()
40
+ for (const entry of granted) {
41
+ if (typeof entry !== 'string') continue
42
+ const trimmed = entry.trim()
43
+ if (!trimmed) continue
44
+ set.add(trimmed)
45
+ }
46
+ return Array.from(set)
47
+ }
48
+
49
+ function indexCatalog(catalog: readonly FeatureDescriptor[]): Map<string, FeatureDescriptor> {
50
+ const map = new Map<string, FeatureDescriptor>()
51
+ for (const descriptor of catalog) {
52
+ if (!descriptor || typeof descriptor.id !== 'string') continue
53
+ const id = descriptor.id.trim()
54
+ if (!id) continue
55
+ if (!map.has(id)) map.set(id, descriptor)
56
+ }
57
+ return map
58
+ }
59
+
60
+ export function resolveAclDependencyDiagnostics(
61
+ granted: readonly string[] | undefined,
62
+ catalog: readonly FeatureDescriptor[] | undefined,
63
+ ): AclDependencyDiagnostics {
64
+ if (!Array.isArray(catalog) || catalog.length === 0) return EMPTY_DIAGNOSTICS
65
+ const grantedList = normalizeGranted(granted)
66
+ if (grantedList.includes('*')) return EMPTY_DIAGNOSTICS
67
+
68
+ const catalogIndex = indexCatalog(catalog)
69
+
70
+ const missingDependencies: MissingDependency[] = []
71
+ const unknownReferences: UnknownReference[] = []
72
+ const dependentsByDependency = new Map<string, Set<string>>()
73
+
74
+ for (const descriptor of catalog) {
75
+ if (!descriptor || typeof descriptor.id !== 'string') continue
76
+ const featureId = descriptor.id.trim()
77
+ if (!featureId) continue
78
+ const deps = Array.isArray(descriptor.dependsOn) ? descriptor.dependsOn : []
79
+ if (deps.length === 0) continue
80
+
81
+ if (!hasFeature(grantedList, featureId)) continue
82
+
83
+ const missing: string[] = []
84
+ const unknown: string[] = []
85
+ for (const rawDep of deps) {
86
+ if (typeof rawDep !== 'string') continue
87
+ const dep = rawDep.trim()
88
+ if (!dep) continue
89
+ const isRegistered = catalogIndex.has(dep) || dep.endsWith('.*') || dep === '*'
90
+ if (!isRegistered) {
91
+ unknown.push(dep)
92
+ continue
93
+ }
94
+ if (!hasFeature(grantedList, dep)) {
95
+ missing.push(dep)
96
+ const bucket = dependentsByDependency.get(dep) ?? new Set<string>()
97
+ bucket.add(featureId)
98
+ dependentsByDependency.set(dep, bucket)
99
+ }
100
+ }
101
+
102
+ if (missing.length > 0) {
103
+ missingDependencies.push({ feature: featureId, missing: dedupe(missing) })
104
+ }
105
+ if (unknown.length > 0) {
106
+ unknownReferences.push({ feature: featureId, missing: dedupe(unknown) })
107
+ }
108
+ }
109
+
110
+ const orphanedDependents: OrphanedDependent[] = []
111
+ for (const [dep, dependents] of dependentsByDependency) {
112
+ const known = catalogIndex.has(dep)
113
+ if (!known) continue
114
+ orphanedDependents.push({
115
+ dependency: dep,
116
+ dependents: Array.from(dependents).sort((a, b) => a.localeCompare(b)),
117
+ })
118
+ }
119
+ orphanedDependents.sort((a, b) => a.dependency.localeCompare(b.dependency))
120
+
121
+ return {
122
+ missingDependencies,
123
+ orphanedDependents,
124
+ unknownReferences: unknownReferences.sort((a, b) => a.feature.localeCompare(b.feature)),
125
+ }
126
+ }
127
+
128
+ function dedupe(values: readonly string[]): string[] {
129
+ return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b))
130
+ }
131
+
132
+ export function applyAddMissingDependency(
133
+ granted: readonly string[],
134
+ dependency: string,
135
+ ): string[] {
136
+ if (!dependency || !dependency.trim()) return [...granted]
137
+ if (granted.includes(dependency)) return [...granted]
138
+ return [...granted, dependency]
139
+ }
140
+
141
+ export function applyRemoveDependents(
142
+ granted: readonly string[],
143
+ dependents: readonly string[],
144
+ ): string[] {
145
+ if (!dependents.length) return [...granted]
146
+ const dropSet = new Set(dependents)
147
+ return granted.filter((feature) => !dropSet.has(feature))
148
+ }
149
+
150
+ export function applyRestoreDependency(
151
+ granted: readonly string[],
152
+ dependency: string,
153
+ ): string[] {
154
+ return applyAddMissingDependency(granted, dependency)
155
+ }