@i4e/invest4edu-access-core 0.4.2 → 0.6.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@i4e/invest4edu-access-core",
3
- "version": "0.4.2",
4
- "description": "Shared access-control primitives (Track 3: tenant keystone, role capabilities, reportee tree, feature flags) for NeoFindesk backends.",
3
+ "version": "0.6.0",
4
+ "description": "Shared access-control primitives for NeoFindesk: tenant keystone, role capabilities, reportee tree, feature flags, and the unified access engine (registry schema, snapshot resolver, visibleWhen).",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.js",
@@ -9,7 +9,13 @@
9
9
  "./tenant-plugin": "./src/tenant-plugin.js",
10
10
  "./role-capabilities": "./src/role-capabilities.js",
11
11
  "./reportee-tree": "./src/reportee-tree.js",
12
- "./access-config": "./src/access-config.js"
12
+ "./access-config": "./src/access-config.js",
13
+ "./access-schema": "./src/access-schema.js",
14
+ "./access-resolver": "./src/access-resolver.js",
15
+ "./visible-when": "./src/visible-when.js"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test test/"
13
19
  },
14
20
  "files": [
15
21
  "src",
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Access snapshot resolver — @i4e/invest4edu-access-core (unified access framework, P1).
3
+ *
4
+ * ONE resolver produces the "access snapshot" for a request: the nav tree, the per-feature
5
+ * allow/actions/entitlement/tracking map, and the product scope. Everything downstream — nav,
6
+ * `hasFeature`, `requireFeature`, grid `visibleWhen`, entitlement, metering — reads this one
7
+ * object, so there is a single place where access is decided.
8
+ *
9
+ * PURE by design: callers inject already-loaded data (`registry`, `grants`, `mappedProducts`, …).
10
+ * No Mongoose, no I/O, no ALS — which is what makes allow/deny matrices testable without a DB.
11
+ *
12
+ * Design: TDD-unified-access-and-subscription.md §4
13
+ */
14
+
15
+ import { parentCodeOf } from "./access-schema.js";
16
+
17
+ /** Reasons a feature can be locked, surfaced so the UI can distinguish upsell from hard-deny. */
18
+ export const ENTITLEMENT_REASONS = Object.freeze({
19
+ OK: "ok",
20
+ ADMIN_BYPASS: "admin_bypass",
21
+ NO_SUBSCRIPTION: "no_subscription",
22
+ TRIAL_ACTIVE: "trial_active",
23
+ TRIAL_EXPIRED: "trial_expired",
24
+ LOCKED: "locked",
25
+ QUOTA_EXCEEDED: "quota_exceeded",
26
+ PAYMENT_OVERDUE: "payment_overdue",
27
+ SUBSCRIPTION_EXPIRED: "subscription_expired",
28
+ });
29
+
30
+ const isLive = (f) =>
31
+ !!f && f.status === "active" && f.kill_switch !== "on";
32
+
33
+ /**
34
+ * Merge grants into an effective allow-set per feature.
35
+ *
36
+ * Rules (design §3.3): union(role allows) − union(user denies) + union(user allows).
37
+ * Deny beats allow at the same subject level; the user level beats the role level.
38
+ *
39
+ * Product dimension: a grant with `product_code: null` is UNSCOPED and contributes the caller's
40
+ * whole product set (legacy-equivalent). A scoped grant contributes only that product, and only
41
+ * when the caller actually has it mapped (default-deny).
42
+ *
43
+ * @returns {Map<string, {products: Set<string>|null}>} null products = unscoped/all
44
+ */
45
+ export function mergeGrants(grants = [], { mappedProducts = [], now = new Date() } = {}) {
46
+ const mapped = new Set(mappedProducts.map(String));
47
+
48
+ const roleAllow = new Map(); // code → Set|null
49
+ const userAllow = new Map();
50
+ const userDeny = new Set();
51
+ const roleDeny = new Set();
52
+
53
+ const addTo = (map, code, product) => {
54
+ if (!map.has(code)) map.set(code, { products: null, anyUnscoped: false });
55
+ const e = map.get(code);
56
+ if (product == null) {
57
+ e.anyUnscoped = true; // unscoped grant wins — covers everything the user has
58
+ e.products = null;
59
+ } else if (!e.anyUnscoped) {
60
+ if (!e.products) e.products = new Set();
61
+ e.products.add(String(product));
62
+ }
63
+ };
64
+
65
+ for (const g of grants) {
66
+ if (!g || !g.feature_code) continue;
67
+ if (g.expires_at && new Date(g.expires_at) <= now) continue; // expired grants are inert
68
+
69
+ // A product-scoped grant only counts when the caller actually has that product mapped.
70
+ if (g.product_code != null && !mapped.has(String(g.product_code))) continue;
71
+
72
+ const isUser = g.subject_type === "user";
73
+ if (g.effect === "deny") {
74
+ (isUser ? userDeny : roleDeny).add(g.feature_code);
75
+ } else {
76
+ addTo(isUser ? userAllow : roleAllow, g.feature_code, g.product_code ?? null);
77
+ }
78
+ }
79
+
80
+ const out = new Map();
81
+ const put = (code, entry) => {
82
+ const prev = out.get(code);
83
+ if (!prev) {
84
+ out.set(code, { products: entry.anyUnscoped ? null : new Set(entry.products || []) });
85
+ return;
86
+ }
87
+ if (prev.products === null || entry.anyUnscoped) prev.products = null;
88
+ else for (const p of entry.products || []) prev.products.add(p);
89
+ };
90
+
91
+ for (const [code, entry] of roleAllow) {
92
+ if (roleDeny.has(code)) continue; // deny beats allow at the same level
93
+ put(code, entry);
94
+ }
95
+ for (const code of userDeny) out.delete(code); // user deny beats role allow
96
+ for (const [code, entry] of userAllow) put(code, entry); // user allow beats role deny
97
+
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * Build the nav tree from the features the caller holds.
103
+ * Screens only, `nav.show_in_nav`, grouped by module and ordered by position.
104
+ *
105
+ * The nav IS the access model projected — not a parallel copy (requirement 2).
106
+ */
107
+ export function buildNav(allowedScreenFeatures = [], modules = []) {
108
+ const modByCode = new Map(
109
+ modules.filter((m) => m && m.status !== "retired").map((m) => [m.module_code, m]),
110
+ );
111
+
112
+ const groups = new Map();
113
+ for (const f of allowedScreenFeatures) {
114
+ if (!f?.nav?.show_in_nav) continue;
115
+ const code = f.module_code;
116
+ const mod = modByCode.get(code);
117
+ if (!mod) continue; // a feature whose module is missing/retired is simply not navigable
118
+ if (!groups.has(code)) {
119
+ groups.set(code, {
120
+ module_code: code,
121
+ name: mod.name,
122
+ icon: mod.icon || null,
123
+ position: mod.position ?? 0,
124
+ items: [],
125
+ });
126
+ }
127
+ groups.get(code).items.push({
128
+ feature_code: f.feature_code,
129
+ name: f.name,
130
+ route: f.route || null,
131
+ position: f.nav.position ?? 0,
132
+ is_external_url: !!f.nav.is_external_url,
133
+ external_url: f.nav.external_url || null,
134
+ });
135
+ }
136
+
137
+ const nav = [...groups.values()].sort((a, b) => a.position - b.position);
138
+ for (const g of nav) g.items.sort((a, b) => a.position - b.position);
139
+ return nav;
140
+ }
141
+
142
+ /**
143
+ * Resolve the access snapshot.
144
+ *
145
+ * @param {Object} p
146
+ * @param {Object} p.identity { account_id, userId, roles[], actingAs? }
147
+ * @param {Object} p.registry { features: [], modules: [] } (already filtered to live rows)
148
+ * @param {Array} p.grants access_grants rows for {account, roles ∪ userId}
149
+ * @param {Array} p.mappedProducts the caller's product codes
150
+ * @param {Object} p.capabilities capsFor(roles) — reads `bypassAllGates` (D27)
151
+ * @param {Function} [p.entitlementFor] (feature) → entitlement block | null
152
+ */
153
+ export function resolveAccessSnapshot({
154
+ identity = {},
155
+ registry = {},
156
+ grants = [],
157
+ mappedProducts = [],
158
+ capabilities = {},
159
+ entitlementFor = null,
160
+ now = new Date(),
161
+ } = {}) {
162
+ const features = (registry.features || []).filter(isLive);
163
+ const modules = registry.modules || [];
164
+
165
+ // ── D27: system-admin break-glass ───────────────────────────────────────────────────────────
166
+ // Resolved ONCE, here — never re-checked per gate, so no gate can forget to honour it. Opens
167
+ // every gate WITHIN the caller's own account. Deliberately NOT cross-tenant: crossing accounts
168
+ // is the separate, stricter canCrossAccount path (D3/BRI-582). bypassAllGates ≠ canCrossAccount.
169
+ const bypass = !!capabilities.bypassAllGates;
170
+
171
+ const allowedByCode = bypass
172
+ ? new Map(features.map((f) => [f.feature_code, { products: null }]))
173
+ : mergeGrants(grants, { mappedProducts, now });
174
+
175
+ const byCode = new Map(features.map((f) => [f.feature_code, f]));
176
+ const productScope = bypass
177
+ ? [...new Set(features.flatMap((f) => f.products || []))]
178
+ : [...new Set(mappedProducts.map(String))];
179
+
180
+ const featureMap = {};
181
+ const allowedScreens = [];
182
+
183
+ for (const f of features) {
184
+ const grant = allowedByCode.get(f.feature_code);
185
+ if (!grant) continue;
186
+
187
+ // Platform-level product constraint on the feature itself, intersected with the grant's.
188
+ let products = grant.products ? [...grant.products] : [...productScope];
189
+ if (Array.isArray(f.products) && f.products.length) {
190
+ const allowedForFeature = new Set(f.products.map(String));
191
+ products = products.filter((p) => allowedForFeature.has(p));
192
+ // A product-constrained feature with no overlap is not reachable at all.
193
+ if (products.length === 0 && !bypass) continue;
194
+ }
195
+
196
+ const entitlement = bypass
197
+ ? {
198
+ unlocked: true,
199
+ quota: null,
200
+ used: 0,
201
+ remaining: null,
202
+ overage_policy: "unlimited",
203
+ reason: ENTITLEMENT_REASONS.ADMIN_BYPASS,
204
+ }
205
+ : entitlementFor
206
+ ? entitlementFor(f)
207
+ : null;
208
+
209
+ featureMap[f.feature_code] = {
210
+ allowed: true,
211
+ products: grant.products === null && !Array.isArray(f.products) ? "*" : products,
212
+ actions: [],
213
+ entitlement,
214
+ tracking: !!f.tracking_enabled,
215
+ };
216
+
217
+ if (f.feature_type === "screen") allowedScreens.push(f);
218
+ }
219
+
220
+ // Attach each allowed action to its parent screen (actions are features, design §2).
221
+ for (const code of Object.keys(featureMap)) {
222
+ const f = byCode.get(code);
223
+ if (f?.feature_type !== "action") continue;
224
+ const parent = f.parent_feature_code || parentCodeOf(code);
225
+ if (parent && featureMap[parent]) featureMap[parent].actions.push(code);
226
+ }
227
+
228
+ return {
229
+ version: 1,
230
+ resolved_at: now.toISOString(),
231
+ resolved_for: {
232
+ userId: identity.userId ? String(identity.userId) : null,
233
+ account_id: identity.account_id ? String(identity.account_id) : null,
234
+ actingAs: identity.actingAs?.giver_id || null,
235
+ real_userId: identity.actingAs?.delegate_id || (identity.userId ? String(identity.userId) : null),
236
+ admin_bypass: bypass,
237
+ },
238
+ nav: buildNav(allowedScreens, modules),
239
+ features: featureMap,
240
+ productScope,
241
+ };
242
+ }
243
+
244
+ /** Snapshot-based `hasFeature` (the server-side twin of the client helper). */
245
+ export function hasFeature(snapshot, featureCode) {
246
+ return !!snapshot?.features?.[featureCode]?.allowed;
247
+ }
248
+
249
+ /** True when the feature is allowed AND (no entitlement gate OR it is unlocked). */
250
+ export function isUsable(snapshot, featureCode) {
251
+ const f = snapshot?.features?.[featureCode];
252
+ if (!f?.allowed) return false;
253
+ return !f.entitlement || f.entitlement.unlocked !== false;
254
+ }
255
+
256
+ export default { resolveAccessSnapshot, mergeGrants, buildNav, hasFeature, isUsable, ENTITLEMENT_REASONS };
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Access-model schema DEFINITIONS — @i4e/invest4edu-access-core (unified access framework, P1).
3
+ *
4
+ * These are PLAIN OBJECTS, not Mongoose schemas (D4: no models in the package). Each backend
5
+ * registers its own models from these definitions, so there is one source of truth for field
6
+ * names without dragging a second Mongoose instance into the package.
7
+ *
8
+ * Design: nfd-ui-nextjs/IV-CodingAgent/specs/access-management/TDD-unified-access-and-subscription.md
9
+ * Status: nfd-ui-nextjs/IV-CodingAgent/specs/access-management/TRACKER-unified-access.md
10
+ *
11
+ * Taxonomy: Module → Feature → Action
12
+ * - a PAGE is a feature (feature_type: 'screen') — retires Page + page_features CSV
13
+ * - an ACTION is a feature (parent_feature_code) — so a plan can entitle an action
14
+ * One `feature_code` namespace serves RBAC, nav, grids, metering and entitlement.
15
+ */
16
+
17
+ /** Feature types. A page is a `screen`; an action is an `action` with a parent. */
18
+ export const FEATURE_TYPES = Object.freeze(["screen", "action", "data", "service", "tool"]);
19
+
20
+ /** Lifecycle status shared by registry collections. */
21
+ export const REGISTRY_STATUSES = Object.freeze(["active", "dormant", "retired"]);
22
+
23
+ /** Per-feature enforcement ladder (mirrors the platform's off→warn→enforce doctrine). */
24
+ export const ROLLOUT_MODES = Object.freeze(["off", "warn", "enforce"]);
25
+
26
+ /** Grant effect. `deny` at the user level replaces the old `revoked_pages`. */
27
+ export const GRANT_EFFECTS = Object.freeze(["allow", "deny"]);
28
+
29
+ /** Subject a grant is attached to. */
30
+ export const GRANT_SUBJECTS = Object.freeze(["role", "user"]);
31
+
32
+ /**
33
+ * `feature_code` convention: MODULE.SCREEN or MODULE.SCREEN.ACTION.
34
+ * Uppercase segments, `_` within a segment, `.` between. Codes are IMMUTABLE once created —
35
+ * they appear in grants, plan entitlements, usage events and grid configs.
36
+ */
37
+ export const FEATURE_CODE_PATTERN = /^[A-Z][A-Z0-9_]*(\.[A-Z][A-Z0-9_]*){1,2}$/;
38
+
39
+ export function isValidFeatureCode(code) {
40
+ return typeof code === "string" && FEATURE_CODE_PATTERN.test(code);
41
+ }
42
+
43
+ /** Derive a screen's code from an action code (`A.B.C` → `A.B`); null if not an action. */
44
+ export function parentCodeOf(featureCode) {
45
+ if (!isValidFeatureCode(featureCode)) return null;
46
+ const parts = featureCode.split(".");
47
+ return parts.length === 3 ? `${parts[0]}.${parts[1]}` : null;
48
+ }
49
+
50
+ /** Module code of any feature code (`A.B[.C]` → `A`). */
51
+ export function moduleCodeOf(featureCode) {
52
+ if (!isValidFeatureCode(featureCode)) return null;
53
+ return featureCode.split(".")[0];
54
+ }
55
+
56
+ // ── Collection definitions ────────────────────────────────────────────────────────────────────
57
+ // `collection` is the Mongo collection name each backend must use, so v1 and v2 agree.
58
+
59
+ /** Global: nav grouping + reporting dimension. Carries no grants. */
60
+ export const ACCESS_MODULE_DEF = Object.freeze({
61
+ collection: "access_modules",
62
+ fields: Object.freeze({
63
+ module_code: { type: "String", required: true, unique: true, index: true },
64
+ name: { type: "String", required: true },
65
+ icon: { type: "String" },
66
+ position: { type: "Number", default: 0 },
67
+ status: { type: "String", enum: REGISTRY_STATUSES, default: "active" },
68
+ }),
69
+ });
70
+
71
+ /**
72
+ * Global: THE feature registry. Absorbs Page, Page.page_features, the flat Feature model, and
73
+ * the subscription feature registry (BRI-674/707 — one vocabulary, structurally).
74
+ */
75
+ export const ACCESS_FEATURE_DEF = Object.freeze({
76
+ collection: "access_features",
77
+ fields: Object.freeze({
78
+ feature_code: { type: "String", required: true, unique: true, index: true, immutable: true },
79
+ name: { type: "String", required: true },
80
+ description: { type: "String" },
81
+ feature_type: { type: "String", enum: FEATURE_TYPES, required: true, index: true },
82
+ module_code: { type: "String", index: true },
83
+ parent_feature_code: { type: "String", default: null, index: true }, // set for actions
84
+
85
+ route: { type: "String", default: null }, // screens only (absorbs page_route_for_web)
86
+ nav: {
87
+ show_in_nav: { type: "Boolean", default: false },
88
+ position: { type: "Number", default: 0 },
89
+ icon: { type: "String" },
90
+ is_external_url: { type: "Boolean", default: false },
91
+ external_url: { type: "String" },
92
+ },
93
+
94
+ products: { type: "[String]", default: [] }, // empty = product-agnostic
95
+
96
+ status: { type: "String", enum: REGISTRY_STATUSES, default: "active", index: true },
97
+ kill_switch: { type: "String", enum: ["on", "off"], default: "off" },
98
+ rollout_mode: { type: "String", enum: ROLLOUT_MODES, default: "off" },
99
+
100
+ // Survives an entitlement block (login, billing/checkout, profile) — see design §7.4.
101
+ always_available: { type: "Boolean", default: false },
102
+
103
+ tracking_enabled: { type: "Boolean", default: false }, // analytics (side DB)
104
+ is_meterable: { type: "Boolean", default: false }, // quota (app DB counters)
105
+ single_usage_purchasable: { type: "Boolean", default: false },
106
+ single_usage_price: { type: "Number", default: null },
107
+
108
+ legacy_page_id: { type: "ObjectId", default: null }, // migration traceability
109
+ created_by: { type: "ObjectId" },
110
+ }),
111
+ });
112
+
113
+ /**
114
+ * Tenant: one row per grant. Replaces RolePrivileges.accessible_pages, UserPrivileges and
115
+ * revoked_pages. Rows (not embedded arrays) so grants are indexable, diffable and expirable.
116
+ */
117
+ export const ACCESS_GRANT_DEF = Object.freeze({
118
+ collection: "access_grants",
119
+ fields: Object.freeze({
120
+ account_id: { type: "ObjectId", required: true, index: true },
121
+ subject_type: { type: "String", enum: GRANT_SUBJECTS, required: true },
122
+ subject_id: { type: "String", required: true }, // role_code | userId
123
+ feature_code: { type: "String", required: true, index: true },
124
+ // null = unscoped (applies across the user's mapped_products) — the legacy-equivalent default.
125
+ // set = applies only when that product is in the user's mapped_products (default-deny).
126
+ product_code: { type: "String", default: null },
127
+ effect: { type: "String", enum: GRANT_EFFECTS, default: "allow" },
128
+ conditions: { type: "Mixed", default: null },
129
+ expires_at: { type: "Date", default: null },
130
+ granted_by: { type: "ObjectId" },
131
+ }),
132
+ indexes: Object.freeze([
133
+ { keys: { account_id: 1, subject_type: 1, subject_id: 1 } },
134
+ { keys: { account_id: 1, feature_code: 1 } },
135
+ ]),
136
+ });
137
+
138
+ export default {
139
+ FEATURE_TYPES,
140
+ REGISTRY_STATUSES,
141
+ ROLLOUT_MODES,
142
+ GRANT_EFFECTS,
143
+ GRANT_SUBJECTS,
144
+ FEATURE_CODE_PATTERN,
145
+ isValidFeatureCode,
146
+ parentCodeOf,
147
+ moduleCodeOf,
148
+ ACCESS_MODULE_DEF,
149
+ ACCESS_FEATURE_DEF,
150
+ ACCESS_GRANT_DEF,
151
+ };
package/src/index.js CHANGED
@@ -18,3 +18,33 @@ export {
18
18
  readEnvFlags,
19
19
  resolveFlags,
20
20
  } from "./access-config.js";
21
+
22
+ // ── Unified access framework (P1) ─────────────────────────────────────────────────────────────
23
+ export {
24
+ FEATURE_TYPES,
25
+ REGISTRY_STATUSES,
26
+ ROLLOUT_MODES,
27
+ GRANT_EFFECTS,
28
+ GRANT_SUBJECTS,
29
+ FEATURE_CODE_PATTERN,
30
+ isValidFeatureCode,
31
+ parentCodeOf,
32
+ moduleCodeOf,
33
+ ACCESS_MODULE_DEF,
34
+ ACCESS_FEATURE_DEF,
35
+ ACCESS_GRANT_DEF,
36
+ } from "./access-schema.js";
37
+ export {
38
+ resolveAccessSnapshot,
39
+ mergeGrants,
40
+ buildNav,
41
+ hasFeature,
42
+ isUsable,
43
+ ENTITLEMENT_REASONS,
44
+ } from "./access-resolver.js";
45
+ export {
46
+ evaluateVisibleWhen,
47
+ filterVisible,
48
+ applyRelabel,
49
+ resolveColumns,
50
+ } from "./visible-when.js";
@@ -13,6 +13,13 @@
13
13
  * canCorrectE1 — may run the audited E1 correction
14
14
  * canCrossAccount — audited sysadmin cross-account path (default: none)
15
15
  * productScoped — pages + records restricted to the user's mapped products
16
+ * bypassAllGates — system-admin break-glass (D27): opens EVERY gate WITHIN the caller's
17
+ * own account (RBAC, nav, entitlement, product scope, grid visibleWhen,
18
+ * kill switches). Deliberately NOT cross-tenant — crossing accounts is
19
+ * the separate, stricter `canCrossAccount` path (D3/BRI-582: config data
20
+ * only, header-driven, logged before bypass). bypassAllGates ≠
21
+ * canCrossAccount. Exists so a broken access config can never lock
22
+ * everyone out of the screens needed to repair it.
16
23
  */
17
24
 
18
25
  export const CAPABILITY_FLAGS = Object.freeze([
@@ -22,6 +29,11 @@ export const CAPABILITY_FLAGS = Object.freeze([
22
29
  "canCorrectE1",
23
30
  "canCrossAccount",
24
31
  "productScoped",
32
+ "bypassAllGates",
33
+ // Edit the GLOBAL feature registry (modules/screens/actions + their flags). Deliberately
34
+ // narrower than canGovernDelegation: that includes per-account BROKER_ADMINs, and the registry
35
+ // is cross-account — one edit changes what every tenant sees.
36
+ "canManageRegistry",
25
37
  ]);
26
38
 
27
39
  /**
@@ -32,8 +44,10 @@ export const CAPABILITY_FLAGS = Object.freeze([
32
44
  * ALLOWED_ROLES (e1) = SUPER_ADMIN, SYSTEM_ADMIN, BROKER_ADMIN, BACKOFFICE_USER
33
45
  */
34
46
  export const DEFAULT_ROLE_CAPABILITIES = Object.freeze({
35
- SUPER_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true },
36
- SYSTEM_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true },
47
+ // bypassAllGates (D27) defaults to the two platform-admin roles. Inert until the access
48
+ // engine reads it, and revocable from the `rolecapabilities` collection with no deploy.
49
+ SUPER_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true, bypassAllGates: true, canManageRegistry: true },
50
+ SYSTEM_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true, bypassAllGates: true, canManageRegistry: true },
37
51
  BROKER_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true },
38
52
  BACKOFFICE_USER: { isFullAccess: true, canCorrectE1: true },
39
53
  HR_USER: { isFullAccess: true },
@@ -0,0 +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 };