@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,269 +1,269 @@
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, PRODUCT_UI_MODES } 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
- // Product scope comes from the IDENTITY, for admins too. The loader already resolves an
177
- // unmapped identity (internal staff, platform admins) to every product code, so this is "all"
178
- // for them without any special case. Deriving a bypass admin's scope from the products declared
179
- // ON FEATURES — as this used to — yields [] whenever no feature declares one, which is the state
180
- // of the registry today: a system admin resolved to NO products, and every feature then inherited
181
- // `products: []`. Kept only as a fallback for the case where identity scope is genuinely empty.
182
- const identityScope = [...new Set(mappedProducts.map(String))];
183
- const productScope = bypass && !identityScope.length
184
- ? [...new Set(features.flatMap((f) => f.products || []))]
185
- : identityScope;
186
-
187
- const featureMap = {};
188
- const allowedScreens = [];
189
-
190
- for (const f of features) {
191
- const grant = allowedByCode.get(f.feature_code);
192
- if (!grant) continue;
193
-
194
- // Platform-level product constraint on the feature itself, intersected with the grant's.
195
- let products = grant.products ? [...grant.products] : [...productScope];
196
- if (Array.isArray(f.products) && f.products.length) {
197
- const allowedForFeature = new Set(f.products.map(String));
198
- products = products.filter((p) => allowedForFeature.has(p));
199
- // A product-constrained feature with no overlap is not reachable at all.
200
- if (products.length === 0 && !bypass) continue;
201
- }
202
-
203
- const entitlement = bypass
204
- ? {
205
- unlocked: true,
206
- quota: null,
207
- used: 0,
208
- remaining: null,
209
- overage_policy: "unlimited",
210
- reason: ENTITLEMENT_REASONS.ADMIN_BYPASS,
211
- }
212
- : entitlementFor
213
- ? entitlementFor(f)
214
- : null;
215
-
216
- featureMap[f.feature_code] = {
217
- allowed: true,
218
- products: grant.products === null && !Array.isArray(f.products) ? "*" : products,
219
- actions: [],
220
- entitlement,
221
- tracking: !!f.tracking_enabled,
222
- // How this SCREEN should present product-keyed things it renders — distinct from
223
- // `products` above, which is *reachability* ("this screen exists only for these
224
- // products"). The two are independent: Open Orders is product-agnostic yet renders
225
- // product tabs that must be filtered, so the behaviour cannot be derived from the
226
- // constraint. Default 'none' keeps every existing screen rendering exactly as today.
227
- product_ui: PRODUCT_UI_MODES.includes(f.product_ui) ? f.product_ui : "none",
228
- };
229
-
230
- if (f.feature_type === "screen") allowedScreens.push(f);
231
- }
232
-
233
- // Attach each allowed action to its parent screen (actions are features, design §2).
234
- for (const code of Object.keys(featureMap)) {
235
- const f = byCode.get(code);
236
- if (f?.feature_type !== "action") continue;
237
- const parent = f.parent_feature_code || parentCodeOf(code);
238
- if (parent && featureMap[parent]) featureMap[parent].actions.push(code);
239
- }
240
-
241
- return {
242
- version: 1,
243
- resolved_at: now.toISOString(),
244
- resolved_for: {
245
- userId: identity.userId ? String(identity.userId) : null,
246
- account_id: identity.account_id ? String(identity.account_id) : null,
247
- actingAs: identity.actingAs?.giver_id || null,
248
- real_userId: identity.actingAs?.delegate_id || (identity.userId ? String(identity.userId) : null),
249
- admin_bypass: bypass,
250
- },
251
- nav: buildNav(allowedScreens, modules),
252
- features: featureMap,
253
- productScope,
254
- };
255
- }
256
-
257
- /** Snapshot-based `hasFeature` (the server-side twin of the client helper). */
258
- export function hasFeature(snapshot, featureCode) {
259
- return !!snapshot?.features?.[featureCode]?.allowed;
260
- }
261
-
262
- /** True when the feature is allowed AND (no entitlement gate OR it is unlocked). */
263
- export function isUsable(snapshot, featureCode) {
264
- const f = snapshot?.features?.[featureCode];
265
- if (!f?.allowed) return false;
266
- return !f.entitlement || f.entitlement.unlocked !== false;
267
- }
268
-
269
- export default { resolveAccessSnapshot, mergeGrants, buildNav, hasFeature, isUsable, ENTITLEMENT_REASONS };
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, PRODUCT_UI_MODES } 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
+ // Product scope comes from the IDENTITY, for admins too. The loader already resolves an
177
+ // unmapped identity (internal staff, platform admins) to every product code, so this is "all"
178
+ // for them without any special case. Deriving a bypass admin's scope from the products declared
179
+ // ON FEATURES — as this used to — yields [] whenever no feature declares one, which is the state
180
+ // of the registry today: a system admin resolved to NO products, and every feature then inherited
181
+ // `products: []`. Kept only as a fallback for the case where identity scope is genuinely empty.
182
+ const identityScope = [...new Set(mappedProducts.map(String))];
183
+ const productScope = bypass && !identityScope.length
184
+ ? [...new Set(features.flatMap((f) => f.products || []))]
185
+ : identityScope;
186
+
187
+ const featureMap = {};
188
+ const allowedScreens = [];
189
+
190
+ for (const f of features) {
191
+ const grant = allowedByCode.get(f.feature_code);
192
+ if (!grant) continue;
193
+
194
+ // Platform-level product constraint on the feature itself, intersected with the grant's.
195
+ let products = grant.products ? [...grant.products] : [...productScope];
196
+ if (Array.isArray(f.products) && f.products.length) {
197
+ const allowedForFeature = new Set(f.products.map(String));
198
+ products = products.filter((p) => allowedForFeature.has(p));
199
+ // A product-constrained feature with no overlap is not reachable at all.
200
+ if (products.length === 0 && !bypass) continue;
201
+ }
202
+
203
+ const entitlement = bypass
204
+ ? {
205
+ unlocked: true,
206
+ quota: null,
207
+ used: 0,
208
+ remaining: null,
209
+ overage_policy: "unlimited",
210
+ reason: ENTITLEMENT_REASONS.ADMIN_BYPASS,
211
+ }
212
+ : entitlementFor
213
+ ? entitlementFor(f)
214
+ : null;
215
+
216
+ featureMap[f.feature_code] = {
217
+ allowed: true,
218
+ products: grant.products === null && !Array.isArray(f.products) ? "*" : products,
219
+ actions: [],
220
+ entitlement,
221
+ tracking: !!f.tracking_enabled,
222
+ // How this SCREEN should present product-keyed things it renders — distinct from
223
+ // `products` above, which is *reachability* ("this screen exists only for these
224
+ // products"). The two are independent: Open Orders is product-agnostic yet renders
225
+ // product tabs that must be filtered, so the behaviour cannot be derived from the
226
+ // constraint. Default 'none' keeps every existing screen rendering exactly as today.
227
+ product_ui: PRODUCT_UI_MODES.includes(f.product_ui) ? f.product_ui : "none",
228
+ };
229
+
230
+ if (f.feature_type === "screen") allowedScreens.push(f);
231
+ }
232
+
233
+ // Attach each allowed action to its parent screen (actions are features, design §2).
234
+ for (const code of Object.keys(featureMap)) {
235
+ const f = byCode.get(code);
236
+ if (f?.feature_type !== "action") continue;
237
+ const parent = f.parent_feature_code || parentCodeOf(code);
238
+ if (parent && featureMap[parent]) featureMap[parent].actions.push(code);
239
+ }
240
+
241
+ return {
242
+ version: 1,
243
+ resolved_at: now.toISOString(),
244
+ resolved_for: {
245
+ userId: identity.userId ? String(identity.userId) : null,
246
+ account_id: identity.account_id ? String(identity.account_id) : null,
247
+ actingAs: identity.actingAs?.giver_id || null,
248
+ real_userId: identity.actingAs?.delegate_id || (identity.userId ? String(identity.userId) : null),
249
+ admin_bypass: bypass,
250
+ },
251
+ nav: buildNav(allowedScreens, modules),
252
+ features: featureMap,
253
+ productScope,
254
+ };
255
+ }
256
+
257
+ /** Snapshot-based `hasFeature` (the server-side twin of the client helper). */
258
+ export function hasFeature(snapshot, featureCode) {
259
+ return !!snapshot?.features?.[featureCode]?.allowed;
260
+ }
261
+
262
+ /** True when the feature is allowed AND (no entitlement gate OR it is unlocked). */
263
+ export function isUsable(snapshot, featureCode) {
264
+ const f = snapshot?.features?.[featureCode];
265
+ if (!f?.allowed) return false;
266
+ return !f.entitlement || f.entitlement.unlocked !== false;
267
+ }
268
+
269
+ export default { resolveAccessSnapshot, mergeGrants, buildNav, hasFeature, isUsable, ENTITLEMENT_REASONS };