@i4e/invest4edu-access-core 0.33.0 → 0.35.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 +0 -2
- package/package.json +2 -1
- package/src/index.js +9 -0
- package/src/report-audience.js +241 -0
package/README.md
CHANGED
|
@@ -112,8 +112,6 @@ Model.find(q).setOptions({ skipTenant: true }); // one query
|
|
|
112
112
|
(`/user-entity-link`) — which hats may coexist on one person
|
|
113
113
|
- `ROLE_TYPE_PRIORITY`, `CLIENT_APP_ROLE_NAMES`, `pickPortalRoleName` (`/portal-roles`) —
|
|
114
114
|
which role names a portal session
|
|
115
|
-
- `normalizeRoute(route)`, `screenForRoute(features, route)` (`/route-screen`) — the screen row
|
|
116
|
-
a route resolves to, so a `MODULE.SCREEN.ACTION` code is read off the registry, never spelled
|
|
117
115
|
|
|
118
116
|
## Not covered
|
|
119
117
|
**Aggregation pipelines** — add an explicit `{ $match: { account_id } }` stage.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i4e/invest4edu-access-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Shared access-control primitives for NeoFindesk: tenant keystone, role capabilities, reportee tree, entity/role vocabulary, feature flags, and the unified access engine (registry schema, snapshot resolver, visibleWhen).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"./grid-schema": "./src/grid-schema.js",
|
|
22
22
|
"./route-features": "./src/route-features.js",
|
|
23
23
|
"./route-screen": "./src/route-screen.js",
|
|
24
|
+
"./report-audience": "./src/report-audience.js",
|
|
24
25
|
"./subscription-lifecycle": "./src/subscription-lifecycle.js",
|
|
25
26
|
"./entitlement-store": "./src/entitlement-store.js",
|
|
26
27
|
"./proration": "./src/proration.js",
|
package/src/index.js
CHANGED
|
@@ -59,6 +59,15 @@ export {
|
|
|
59
59
|
ACCESS_GRANT_DEF,
|
|
60
60
|
} from "./access-schema.js";
|
|
61
61
|
export { normalizeRoute, screenForRoute } from "./route-screen.js";
|
|
62
|
+
export {
|
|
63
|
+
AUDIENCE,
|
|
64
|
+
SECTION_KEYS,
|
|
65
|
+
ALL_VISIBLE,
|
|
66
|
+
AUDIENCE_DEFAULTS,
|
|
67
|
+
classifyAudience,
|
|
68
|
+
resolveSections,
|
|
69
|
+
resolveReportAudience,
|
|
70
|
+
} from "./report-audience.js";
|
|
62
71
|
export {
|
|
63
72
|
resolveAccessSnapshot,
|
|
64
73
|
mergeGrants,
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portfolio report audience — who is a report FOR, and which sections may it show.
|
|
3
|
+
*
|
|
4
|
+
* A product review (Aditya, Manoj, Rozy) decided the portfolio report must show different things
|
|
5
|
+
* to different audiences: a partner's client and an employee-serviced client see a reduced,
|
|
6
|
+
* B2C-toned view (health score and storytelling, no recommendations), while an adviser working
|
|
7
|
+
* their own book keeps the full report.
|
|
8
|
+
*
|
|
9
|
+
* ── The audience is a property of the CLIENT, never the viewer ────────────────────────────────
|
|
10
|
+
* One RM opens a partner's client and a direct client in the same session, and the InvestValue
|
|
11
|
+
* mobile app is always a client viewer while its users are clients of BOTH types. So the answer
|
|
12
|
+
* is derived from the client record and obeyed identically by every renderer — NFD web, the
|
|
13
|
+
* native mobile screens, and anything that comes later.
|
|
14
|
+
*
|
|
15
|
+
* ── Why this lives in the package ─────────────────────────────────────────────────────────────
|
|
16
|
+
* The same rule is needed in v1 (which fronts the eCAS registry) and v2 (which mints the PFA
|
|
17
|
+
* subject token and answers PFA's callbacks). Two copies of a classification rule is how the two
|
|
18
|
+
* halves drift into disagreeing about what a partner's client may see — and a drift here is not a
|
|
19
|
+
* cosmetic bug: it decides what a client is shown about their own money.
|
|
20
|
+
*
|
|
21
|
+
* ── PURE, like the rest of this package ───────────────────────────────────────────────────────
|
|
22
|
+
* No Mongoose, no I/O. The two identity lookups are INJECTED, because they are the one part that
|
|
23
|
+
* genuinely differs: v1 has Mongoose models, v2 reads raw collections, and a script may have
|
|
24
|
+
* neither. Injecting them keeps the decision testable without a database and keeps the package
|
|
25
|
+
* free of every repo's model graph — the same contract `access-resolver` and `route-features`
|
|
26
|
+
* already follow.
|
|
27
|
+
*
|
|
28
|
+
* ── FAIL OPEN, ALWAYS ─────────────────────────────────────────────────────────────────────────
|
|
29
|
+
* An unknown audience, an unreadable registry, a thrown lookup — all resolve to the full report.
|
|
30
|
+
* A gating bug that blanks a working report is worse than one that shows too much, and this code
|
|
31
|
+
* sits in front of a screen clients already use.
|
|
32
|
+
*
|
|
33
|
+
* ── What this is NOT ──────────────────────────────────────────────────────────────────────────
|
|
34
|
+
* Presentation, not confidentiality. The sections withheld are the client's own portfolio data
|
|
35
|
+
* shown in less depth, and the map reaches the renderers through channels a caller can influence.
|
|
36
|
+
* It must never become the only thing standing between a viewer and data they may not see; if a
|
|
37
|
+
* genuinely must-not-see section is ever added, it needs enforcement at the data source instead.
|
|
38
|
+
* Decision recorded 10 Sep 2026.
|
|
39
|
+
*
|
|
40
|
+
* Spec: IV-CodingAgent/specs/access-management/SPEC-portfolio-report-audience.md
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** Audiences. Surface-neutral: every renderer maps these to its own components. */
|
|
44
|
+
export const AUDIENCE = Object.freeze({
|
|
45
|
+
PARTNER: "partner",
|
|
46
|
+
RM_E1: "rm_e1",
|
|
47
|
+
DIRECT: "direct",
|
|
48
|
+
FULL: "full",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Section keys. Deliberately named for WHAT they are, not for the component that renders them,
|
|
53
|
+
* because independent renderers consume this: nfd-ui gates React components, the mobile app
|
|
54
|
+
* filters its tab array. A key named after either one would read as foreign in the other.
|
|
55
|
+
*
|
|
56
|
+
* `healthScore` has no entry: the gauge is the one thing every audience keeps, so it is never
|
|
57
|
+
* gated. Making it configurable would invite a state where the report shows nothing at all.
|
|
58
|
+
*/
|
|
59
|
+
export const SECTION_KEYS = Object.freeze([
|
|
60
|
+
"actions", // the 3-step action plan / recommendations
|
|
61
|
+
"phc", // portfolio health-check detail body
|
|
62
|
+
"chat", // AI chat
|
|
63
|
+
"performance", // performance / gains
|
|
64
|
+
"keyInsights",
|
|
65
|
+
"fundAnalysis", // the fund analysis table
|
|
66
|
+
"scoreBreakdownFunds", // underlying funds inside the score breakdown
|
|
67
|
+
"advisorCta", // "call your advisor" prompt
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/** Everything on. The answer whenever we cannot prove a narrower one is correct. */
|
|
71
|
+
export const ALL_VISIBLE = Object.freeze({
|
|
72
|
+
actions: true,
|
|
73
|
+
phc: true,
|
|
74
|
+
chat: true,
|
|
75
|
+
performance: true,
|
|
76
|
+
keyInsights: true,
|
|
77
|
+
fundAnalysis: true,
|
|
78
|
+
scoreBreakdownFunds: true,
|
|
79
|
+
advisorCta: false, // opt-in, not part of "everything"
|
|
80
|
+
fundDetail: "full",
|
|
81
|
+
tone: "adviser",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/** The reduced view. Partner and RM-E1 clients get exactly this; direct adds the CTA. */
|
|
85
|
+
const REDUCED = Object.freeze({
|
|
86
|
+
actions: false,
|
|
87
|
+
phc: false,
|
|
88
|
+
chat: false,
|
|
89
|
+
performance: false,
|
|
90
|
+
keyInsights: false,
|
|
91
|
+
fundAnalysis: false,
|
|
92
|
+
scoreBreakdownFunds: false,
|
|
93
|
+
advisorCta: false,
|
|
94
|
+
fundDetail: "minimal",
|
|
95
|
+
tone: "b2c",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Defaults encode the review notes. They are the FALLBACK, not the source of truth — the registry
|
|
100
|
+
* overrides them per feature. They exist so the resolver still answers correctly before the
|
|
101
|
+
* registry rows are seeded, and when the registry cannot be read.
|
|
102
|
+
*/
|
|
103
|
+
export const AUDIENCE_DEFAULTS = Object.freeze({
|
|
104
|
+
[AUDIENCE.FULL]: ALL_VISIBLE,
|
|
105
|
+
|
|
106
|
+
/*
|
|
107
|
+
* REVISED 12 Sep 2026 (Peeyush). The first cut reduced all three audiences and differed only by
|
|
108
|
+
* a CTA. It now reduces ONE.
|
|
109
|
+
*
|
|
110
|
+
* partner full — unlocking a partner's client is out of scope, so gating them would leave a
|
|
111
|
+
* reduced report with no way to ever open it.
|
|
112
|
+
* direct full — a direct client has no partner and no servicing RM, so there is nobody for
|
|
113
|
+
* "ask your advisor" to point at.
|
|
114
|
+
* rm_e1 reduced — an employee services this client, so the withheld sections have an owner
|
|
115
|
+
* who can walk the client through them.
|
|
116
|
+
*
|
|
117
|
+
* Measured on prod the day this changed: 13,327 partner, 2,121 e1, 232 direct of 15,680. So the
|
|
118
|
+
* feature applies to ~13.5% of the book and 86.5% is untouched.
|
|
119
|
+
*/
|
|
120
|
+
[AUDIENCE.PARTNER]: ALL_VISIBLE,
|
|
121
|
+
|
|
122
|
+
// "Don't show for RM clients where RM is E1. Actions will not come. Portfolio Health Score
|
|
123
|
+
// component — don't show; only show Health Score, no recommendations. Hide the AI chatbot.
|
|
124
|
+
// Remove Key / Fund Analysis. Hide Performance. Less fund details. Score breakdown — remove
|
|
125
|
+
// underlying fund. Language B2C / neutral."
|
|
126
|
+
//
|
|
127
|
+
// The CTA rides HERE rather than on `direct`, inverting the first cut: this is the audience
|
|
128
|
+
// with an adviser to talk to, and the one whose sections are withheld.
|
|
129
|
+
[AUDIENCE.RM_E1]: Object.freeze({ ...REDUCED, advisorCta: true }),
|
|
130
|
+
|
|
131
|
+
[AUDIENCE.DIRECT]: ALL_VISIBLE,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const E1_TYPE_DISTRIBUTOR = "distributor";
|
|
135
|
+
|
|
136
|
+
/** A lookup that was not supplied, or threw, proves nothing. Never let it throw upward. */
|
|
137
|
+
async function safeAsk(fn, id) {
|
|
138
|
+
if (typeof fn !== "function") return false;
|
|
139
|
+
try {
|
|
140
|
+
return !!(await fn(id));
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Classify a client record into an audience.
|
|
148
|
+
*
|
|
149
|
+
* `e1_type` is a HINT and is treated as one — the same rule `shapePersonFilterOptions` follows,
|
|
150
|
+
* for the same reason: on devpoc 66 rows carry a type that disagrees with the collection the id is
|
|
151
|
+
* actually in (29 on dev), and those rows predate the field being declared. So both collections
|
|
152
|
+
* are consulted and the hint only decides which answer wins when both could be true. Trusting the
|
|
153
|
+
* enum bare would misclassify exactly those rows — and misclassifying is how a partner's client
|
|
154
|
+
* ends up seeing an adviser's report.
|
|
155
|
+
*
|
|
156
|
+
* @param {{ distributor_id?: *, e1?: *, e1_type?: string }} client
|
|
157
|
+
* @param {Object} [lookups]
|
|
158
|
+
* @param {(id: *) => Promise<boolean>} [lookups.isEmployee] does this id name an employee-backed user?
|
|
159
|
+
* @param {(id: *) => Promise<boolean>} [lookups.isDistributor] does this id name a real distributor?
|
|
160
|
+
* @returns {Promise<'partner'|'rm_e1'|'direct'>}
|
|
161
|
+
*/
|
|
162
|
+
export async function classifyAudience(client, lookups = {}) {
|
|
163
|
+
if (!client) return AUDIENCE.DIRECT;
|
|
164
|
+
|
|
165
|
+
// A client sitting in a partner's book is a partner client, whatever E1 says. This is the
|
|
166
|
+
// strongest signal we have and it is a plain field — house clients were moved to direct by
|
|
167
|
+
// nulling it (dedup, Aug 2026), so `null` genuinely means "not a partner's".
|
|
168
|
+
if (client.distributor_id) return AUDIENCE.PARTNER;
|
|
169
|
+
|
|
170
|
+
const { e1 } = client;
|
|
171
|
+
if (!e1) return AUDIENCE.DIRECT;
|
|
172
|
+
|
|
173
|
+
// Ask both, then let the hint break the tie — never the other way round.
|
|
174
|
+
const [employee, distributor] = await Promise.all([
|
|
175
|
+
safeAsk(lookups.isEmployee, e1),
|
|
176
|
+
safeAsk(lookups.isDistributor, e1),
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
if (employee && distributor) {
|
|
180
|
+
return client.e1_type === E1_TYPE_DISTRIBUTOR ? AUDIENCE.PARTNER : AUDIENCE.RM_E1;
|
|
181
|
+
}
|
|
182
|
+
if (employee) return AUDIENCE.RM_E1;
|
|
183
|
+
if (distributor) return AUDIENCE.PARTNER;
|
|
184
|
+
|
|
185
|
+
// An E1 resolving to nobody (deleted user, another account's distributor) is exactly the
|
|
186
|
+
// bad-data case above. Fall back to the reduced-but-safe audience rather than to `full`.
|
|
187
|
+
return AUDIENCE.DIRECT;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Merge registry overrides over the audience defaults.
|
|
192
|
+
*
|
|
193
|
+
* The registry is the source of truth for WHICH sections exist and whether each is on; the
|
|
194
|
+
* defaults answer while the rows are unseeded. `loadOverrides` is injected rather than imported so
|
|
195
|
+
* this module stays free of the access models — and so a caller with no registry at all (the
|
|
196
|
+
* mobile path has no access snapshot: no roles, no grants) can skip the read entirely without a
|
|
197
|
+
* second code path.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} audience
|
|
200
|
+
* @param {(audience: string) => Promise<Object|null>} [loadOverrides]
|
|
201
|
+
*/
|
|
202
|
+
export async function resolveSections(audience, loadOverrides) {
|
|
203
|
+
const base = AUDIENCE_DEFAULTS[audience] || ALL_VISIBLE;
|
|
204
|
+
if (typeof loadOverrides !== "function") return { ...base };
|
|
205
|
+
try {
|
|
206
|
+
const overrides = await loadOverrides(audience);
|
|
207
|
+
return overrides && typeof overrides === "object" ? { ...base, ...overrides } : { ...base };
|
|
208
|
+
} catch {
|
|
209
|
+
return { ...base }; // registry unreadable → the audience default still applies
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The one entry point callers use: classify, resolve, and never throw.
|
|
215
|
+
*
|
|
216
|
+
* @param {Object} client client-ish record (distributor_id, e1, e1_type)
|
|
217
|
+
* @param {Object} [opts]
|
|
218
|
+
* @param {(id: *) => Promise<boolean>} [opts.isEmployee]
|
|
219
|
+
* @param {(id: *) => Promise<boolean>} [opts.isDistributor]
|
|
220
|
+
* @param {(audience: string) => Promise<Object|null>} [opts.loadOverrides]
|
|
221
|
+
* @returns {Promise<{ audience: string, sections: Object }>}
|
|
222
|
+
*/
|
|
223
|
+
export async function resolveReportAudience(client, opts = {}) {
|
|
224
|
+
try {
|
|
225
|
+
const audience = await classifyAudience(client, opts);
|
|
226
|
+
return { audience, sections: await resolveSections(audience, opts.loadOverrides) };
|
|
227
|
+
} catch {
|
|
228
|
+
// Fail open. Every caller renders a report from this; none of them should render nothing.
|
|
229
|
+
return { audience: AUDIENCE.FULL, sections: { ...ALL_VISIBLE } };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export default {
|
|
234
|
+
AUDIENCE,
|
|
235
|
+
SECTION_KEYS,
|
|
236
|
+
ALL_VISIBLE,
|
|
237
|
+
AUDIENCE_DEFAULTS,
|
|
238
|
+
classifyAudience,
|
|
239
|
+
resolveSections,
|
|
240
|
+
resolveReportAudience,
|
|
241
|
+
};
|