@i4e/invest4edu-access-core 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,108 +1,108 @@
1
- /**
2
- * `visibleWhen` — ONE predicate language, ONE evaluator.
3
- * @i4e/invest4edu-access-core (unified access framework, P1)
4
- *
5
- * The same function gates nav items, screen actions, grid columns, filters, row/bulk actions,
6
- * exports and summary metrics. That is what makes "driven by product and roles" true across the
7
- * whole app instead of per-screen — and it means a rule is written once, in data, not in 139
8
- * hardcoded predicates.
9
- *
10
- * Client-side evaluation is UX ONLY. Servers MUST re-evaluate before returning data, projecting
11
- * columns, or building an export/summary — never trust a client-supplied column list.
12
- *
13
- * Design: TDD-unified-access-and-subscription.md §8.3
14
- */
15
-
16
- import { hasFeature, isUsable } from "./access-resolver.js";
17
-
18
- /**
19
- * Shape (every key optional; all present keys must pass — AND):
20
- * {
21
- * features: ["ORDERS.LIST"] // caller must hold all of these
22
- * actions: ["ORDERS.LIST.CANCEL"] // alias of features, reads better on actions
23
- * entitled: "TOOLS.AI_REPORT" // must be allowed AND entitlement-unlocked
24
- * products: ["IVP003"] // caller's product scope must intersect
25
- * distributorLevel: ["D1","D2"]
26
- * screenType: "openorders" | ["a","b"]
27
- * roles: ["BROKER_ADMIN"] // LEGACY escape hatch — lint-flagged, being removed
28
- * anyOf: [ {...}, {...} ] // OR of nested predicates
29
- * not: { ... } // negation
30
- * }
31
- *
32
- * @param {Object|null|undefined} rule absent/empty ⇒ visible (opt-in gating, not opt-out)
33
- * @param {Object} ctx { snapshot, productScope?, distributorLevel?, screenType?, roles? }
34
- */
35
- export function evaluateVisibleWhen(rule, ctx = {}) {
36
- if (!rule || typeof rule !== "object") return true;
37
-
38
- const snapshot = ctx.snapshot || null;
39
- // A system admin (D27) resolves a fully-open snapshot, so feature/entitlement checks below
40
- // pass naturally. Product/level predicates still evaluate — an admin seeing a D1-only column
41
- // labelled for D1 would be misleading, not permissive.
42
- const productScope = new Set(
43
- (ctx.productScope || snapshot?.productScope || []).map(String),
44
- );
45
-
46
- const asArray = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
47
-
48
- if (rule.features && !asArray(rule.features).every((c) => hasFeature(snapshot, c))) return false;
49
- if (rule.actions && !asArray(rule.actions).every((c) => hasFeature(snapshot, c))) return false;
50
- if (rule.entitled && !asArray(rule.entitled).every((c) => isUsable(snapshot, c))) return false;
51
-
52
- if (rule.products) {
53
- const want = asArray(rule.products).map(String);
54
- if (want.length && !want.some((p) => productScope.has(p))) return false;
55
- }
56
-
57
- if (rule.distributorLevel) {
58
- const lvl = ctx.distributorLevel ? String(ctx.distributorLevel) : null;
59
- if (!lvl || !asArray(rule.distributorLevel).map(String).includes(lvl)) return false;
60
- }
61
-
62
- if (rule.screenType) {
63
- const st = ctx.screenType ? String(ctx.screenType) : null;
64
- if (!st || !asArray(rule.screenType).map(String).includes(st)) return false;
65
- }
66
-
67
- // LEGACY: kept only so migration can proceed screen-by-screen. New configs must not use it.
68
- if (rule.roles) {
69
- const held = new Set(asArray(ctx.roles || snapshot?.resolved_for?.roles).map(String));
70
- if (!asArray(rule.roles).map(String).some((r) => held.has(r))) return false;
71
- }
72
-
73
- if (rule.anyOf) {
74
- const branches = asArray(rule.anyOf);
75
- if (branches.length && !branches.some((b) => evaluateVisibleWhen(b, ctx))) return false;
76
- }
77
-
78
- if (rule.not && evaluateVisibleWhen(rule.not, ctx)) return false;
79
-
80
- return true;
81
- }
82
-
83
- /** Filter a list of `{visibleWhen}`-bearing config items (columns, metrics, actions, filters). */
84
- export function filterVisible(items = [], ctx = {}) {
85
- return items.filter((i) => evaluateVisibleWhen(i?.visibleWhen, ctx));
86
- }
87
-
88
- /**
89
- * Apply `relabelWhen` overrides to a grid column — absorbs the imperative relabelling in
90
- * order-constants (e.g. d1/d2/d3_payout → "Net Income" for the matching distributor level).
91
- * First matching rule wins; the column is returned unchanged when nothing matches.
92
- */
93
- export function applyRelabel(column, ctx = {}) {
94
- if (!column?.relabelWhen?.length) return column;
95
- for (const r of column.relabelWhen) {
96
- if (evaluateVisibleWhen(r?.when, ctx)) {
97
- return { ...column, header: r.header ?? column.header, label: r.label ?? column.label };
98
- }
99
- }
100
- return column;
101
- }
102
-
103
- /** Resolve a full column list: drop hidden ones, apply relabelling, preserve order. */
104
- export function resolveColumns(columns = [], ctx = {}) {
105
- return filterVisible(columns, ctx).map((c) => applyRelabel(c, ctx));
106
- }
107
-
108
- export default { evaluateVisibleWhen, filterVisible, applyRelabel, resolveColumns };
1
+ /**
2
+ * `visibleWhen` — ONE predicate language, ONE evaluator.
3
+ * @i4e/invest4edu-access-core (unified access framework, P1)
4
+ *
5
+ * The same function gates nav items, screen actions, grid columns, filters, row/bulk actions,
6
+ * exports and summary metrics. That is what makes "driven by product and roles" true across the
7
+ * whole app instead of per-screen — and it means a rule is written once, in data, not in 139
8
+ * hardcoded predicates.
9
+ *
10
+ * Client-side evaluation is UX ONLY. Servers MUST re-evaluate before returning data, projecting
11
+ * columns, or building an export/summary — never trust a client-supplied column list.
12
+ *
13
+ * Design: TDD-unified-access-and-subscription.md §8.3
14
+ */
15
+
16
+ import { hasFeature, isUsable } from "./access-resolver.js";
17
+
18
+ /**
19
+ * Shape (every key optional; all present keys must pass — AND):
20
+ * {
21
+ * features: ["ORDERS.LIST"] // caller must hold all of these
22
+ * actions: ["ORDERS.LIST.CANCEL"] // alias of features, reads better on actions
23
+ * entitled: "TOOLS.AI_REPORT" // must be allowed AND entitlement-unlocked
24
+ * products: ["IVP003"] // caller's product scope must intersect
25
+ * distributorLevel: ["D1","D2"]
26
+ * screenType: "openorders" | ["a","b"]
27
+ * roles: ["BROKER_ADMIN"] // LEGACY escape hatch — lint-flagged, being removed
28
+ * anyOf: [ {...}, {...} ] // OR of nested predicates
29
+ * not: { ... } // negation
30
+ * }
31
+ *
32
+ * @param {Object|null|undefined} rule absent/empty ⇒ visible (opt-in gating, not opt-out)
33
+ * @param {Object} ctx { snapshot, productScope?, distributorLevel?, screenType?, roles? }
34
+ */
35
+ export function evaluateVisibleWhen(rule, ctx = {}) {
36
+ if (!rule || typeof rule !== "object") return true;
37
+
38
+ const snapshot = ctx.snapshot || null;
39
+ // A system admin (D27) resolves a fully-open snapshot, so feature/entitlement checks below
40
+ // pass naturally. Product/level predicates still evaluate — an admin seeing a D1-only column
41
+ // labelled for D1 would be misleading, not permissive.
42
+ const productScope = new Set(
43
+ (ctx.productScope || snapshot?.productScope || []).map(String),
44
+ );
45
+
46
+ const asArray = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
47
+
48
+ if (rule.features && !asArray(rule.features).every((c) => hasFeature(snapshot, c))) return false;
49
+ if (rule.actions && !asArray(rule.actions).every((c) => hasFeature(snapshot, c))) return false;
50
+ if (rule.entitled && !asArray(rule.entitled).every((c) => isUsable(snapshot, c))) return false;
51
+
52
+ if (rule.products) {
53
+ const want = asArray(rule.products).map(String);
54
+ if (want.length && !want.some((p) => productScope.has(p))) return false;
55
+ }
56
+
57
+ if (rule.distributorLevel) {
58
+ const lvl = ctx.distributorLevel ? String(ctx.distributorLevel) : null;
59
+ if (!lvl || !asArray(rule.distributorLevel).map(String).includes(lvl)) return false;
60
+ }
61
+
62
+ if (rule.screenType) {
63
+ const st = ctx.screenType ? String(ctx.screenType) : null;
64
+ if (!st || !asArray(rule.screenType).map(String).includes(st)) return false;
65
+ }
66
+
67
+ // LEGACY: kept only so migration can proceed screen-by-screen. New configs must not use it.
68
+ if (rule.roles) {
69
+ const held = new Set(asArray(ctx.roles || snapshot?.resolved_for?.roles).map(String));
70
+ if (!asArray(rule.roles).map(String).some((r) => held.has(r))) return false;
71
+ }
72
+
73
+ if (rule.anyOf) {
74
+ const branches = asArray(rule.anyOf);
75
+ if (branches.length && !branches.some((b) => evaluateVisibleWhen(b, ctx))) return false;
76
+ }
77
+
78
+ if (rule.not && evaluateVisibleWhen(rule.not, ctx)) return false;
79
+
80
+ return true;
81
+ }
82
+
83
+ /** Filter a list of `{visibleWhen}`-bearing config items (columns, metrics, actions, filters). */
84
+ export function filterVisible(items = [], ctx = {}) {
85
+ return items.filter((i) => evaluateVisibleWhen(i?.visibleWhen, ctx));
86
+ }
87
+
88
+ /**
89
+ * Apply `relabelWhen` overrides to a grid column — absorbs the imperative relabelling in
90
+ * order-constants (e.g. d1/d2/d3_payout → "Net Income" for the matching distributor level).
91
+ * First matching rule wins; the column is returned unchanged when nothing matches.
92
+ */
93
+ export function applyRelabel(column, ctx = {}) {
94
+ if (!column?.relabelWhen?.length) return column;
95
+ for (const r of column.relabelWhen) {
96
+ if (evaluateVisibleWhen(r?.when, ctx)) {
97
+ return { ...column, header: r.header ?? column.header, label: r.label ?? column.label };
98
+ }
99
+ }
100
+ return column;
101
+ }
102
+
103
+ /** Resolve a full column list: drop hidden ones, apply relabelling, preserve order. */
104
+ export function resolveColumns(columns = [], ctx = {}) {
105
+ return filterVisible(columns, ctx).map((c) => applyRelabel(c, ctx));
106
+ }
107
+
108
+ export default { evaluateVisibleWhen, filterVisible, applyRelabel, resolveColumns };