@i4e/invest4edu-access-core 0.30.0 → 0.32.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.
- package/README.md +126 -86
- package/package.json +56 -53
- package/src/access-config.js +68 -68
- package/src/access-resolver.js +269 -269
- package/src/access-schema.js +174 -174
- package/src/credits.js +128 -128
- package/src/entitlement-schema.js +194 -194
- package/src/entitlement-store.d.ts +99 -99
- package/src/entitlement-store.js +610 -589
- package/src/entitlement.js +300 -300
- package/src/entity-status.js +165 -0
- package/src/grid-schema.js +231 -231
- package/src/index.js +74 -57
- package/src/portal-roles.js +66 -0
- package/src/proration.js +155 -155
- package/src/reportee-tree.js +129 -35
- package/src/role-capabilities.js +93 -93
- package/src/route-features.js +292 -292
- package/src/subscription-lifecycle.js +106 -106
- package/src/tenant-context.js +26 -26
- package/src/tenant-plugin.js +177 -177
- package/src/user-entity-link.js +58 -0
- package/src/visible-when.js +108 -108
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which hats may coexist on one person — @i4e/invest4edu-access-core.
|
|
3
|
+
*
|
|
4
|
+
* Historically every entity flow minted its own `users` row, so the same person signing
|
|
5
|
+
* up as a partner after already being a client was refused outright. One person is now
|
|
6
|
+
* one row carrying a link per entity, which makes "may this person also be X" a real
|
|
7
|
+
* question with a real answer — and it has to be the SAME answer in every flow, or the
|
|
8
|
+
* employee path enforces something the partner path does not.
|
|
9
|
+
*
|
|
10
|
+
* Only the rule table lives here. Resolving which hats a person currently wears is each
|
|
11
|
+
* backend's job: v1 reads `users` columns plus `userentitymappings`, v2 reads its own
|
|
12
|
+
* narrower projection, and neither shape belongs in a pure package. What must not differ
|
|
13
|
+
* is the verdict, so the verdict is what is shared.
|
|
14
|
+
*/
|
|
15
|
+
import { ENTITY } from './entity-status.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Entities that may not coexist on one person.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately minimal: it states the one prohibition the business asked for rather than
|
|
21
|
+
* enumerating every pair. Employee and partner are mutually exclusive; everything else
|
|
22
|
+
* that already happens keeps happening — a client links onto an employee today, and
|
|
23
|
+
* refusing that here would break a live flow.
|
|
24
|
+
*
|
|
25
|
+
* Extending this is a line in the table, not a new check somewhere else.
|
|
26
|
+
*/
|
|
27
|
+
export const CONFLICTING_ENTITIES = {
|
|
28
|
+
[ENTITY.DISTRIBUTOR]: [ENTITY.EMPLOYEE],
|
|
29
|
+
[ENTITY.EMPLOYEE]: [ENTITY.DISTRIBUTOR],
|
|
30
|
+
[ENTITY.CLIENT]: [],
|
|
31
|
+
[ENTITY.MANUFACTURER]: [],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Which already-worn hat, if any, forbids adding this one.
|
|
36
|
+
*
|
|
37
|
+
* Takes a plain set of entity types so it stays independent of how they were discovered —
|
|
38
|
+
* the mapping collection, the legacy columns, or a mix of both all reach the same table.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} targetEntity the hat being added, one of ENTITY
|
|
41
|
+
* @param {Set<string>|Array} existing the hats already worn
|
|
42
|
+
* @returns {string|null} the conflicting entity type, or null when the pairing is allowed
|
|
43
|
+
*/
|
|
44
|
+
export function conflictingEntityFor(targetEntity, existing) {
|
|
45
|
+
const worn = existing instanceof Set ? existing : new Set(existing || []);
|
|
46
|
+
return (CONFLICTING_ENTITIES[targetEntity] || []).find((e) => worn.has(e)) || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The refusal message for a pairing the table forbids. Shared so both backends say the same thing. */
|
|
50
|
+
export function entityConflictMessage(targetEntity, conflictingEntity) {
|
|
51
|
+
if (targetEntity === ENTITY.DISTRIBUTOR && conflictingEntity === ENTITY.EMPLOYEE) {
|
|
52
|
+
return 'These contact details belong to an employee. An employee cannot also be registered as a partner.';
|
|
53
|
+
}
|
|
54
|
+
if (targetEntity === ENTITY.EMPLOYEE && conflictingEntity === ENTITY.DISTRIBUTOR) {
|
|
55
|
+
return 'These contact details belong to a partner. A partner cannot also be registered as an employee.';
|
|
56
|
+
}
|
|
57
|
+
return `These contact details are already registered as a ${conflictingEntity}, which cannot be combined with a ${targetEntity}.`;
|
|
58
|
+
}
|
package/src/visible-when.js
CHANGED
|
@@ -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 };
|