@i4e/invest4edu-access-core 0.32.0 → 0.33.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 +128 -126
- package/package.json +57 -56
- 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 -610
- package/src/entitlement.js +300 -300
- package/src/grid-schema.js +231 -231
- package/src/index.js +75 -74
- package/src/proration.js +155 -155
- package/src/reportee-tree.js +129 -129
- package/src/role-capabilities.js +93 -93
- package/src/route-features.js +292 -292
- package/src/route-screen.js +45 -0
- package/src/subscription-lifecycle.js +106 -106
- package/src/tenant-context.js +26 -26
- package/src/tenant-plugin.js +177 -177
- package/src/visible-when.js +108 -108
package/src/access-resolver.js
CHANGED
|
@@ -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 };
|