@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/security/aclDependencies.js +106 -0
- package/dist/security/aclDependencies.js.map +7 -0
- package/package.json +2 -2
- package/src/security/__tests__/aclDependencies.test.ts +177 -0
- package/src/security/aclDependencies.ts +155 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:shared] found
|
|
1
|
+
[build:shared] found 212 entry points
|
|
2
2
|
[build:shared] built successfully
|
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.6.4-develop.
|
|
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": "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",
|
|
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.
|
|
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.
|
|
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
|
+
}
|