@i4e/invest4edu-access-core 0.33.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.
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.33.0",
3
+ "version": "0.34.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,226 @@
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
+ // "Don't show for Partner clients & RM clients where RM is E1. Actions will not come.
107
+ // Portfolio Health Score component — don't show; only show Health Score, no recommendations.
108
+ // Hide the AI chatbot. Remove Key / Fund Analysis. Hide Performance. Less fund details.
109
+ // Score breakdown — remove underlying fund. Language B2C / neutral."
110
+ [AUDIENCE.PARTNER]: REDUCED,
111
+
112
+ // Same treatment as a partner client — the review grouped them in one line.
113
+ [AUDIENCE.RM_E1]: REDUCED,
114
+
115
+ // "Direct Clients — call advisor to get details to know the plan. Same storytelling."
116
+ [AUDIENCE.DIRECT]: Object.freeze({ ...REDUCED, advisorCta: true }),
117
+ });
118
+
119
+ const E1_TYPE_DISTRIBUTOR = "distributor";
120
+
121
+ /** A lookup that was not supplied, or threw, proves nothing. Never let it throw upward. */
122
+ async function safeAsk(fn, id) {
123
+ if (typeof fn !== "function") return false;
124
+ try {
125
+ return !!(await fn(id));
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Classify a client record into an audience.
133
+ *
134
+ * `e1_type` is a HINT and is treated as one — the same rule `shapePersonFilterOptions` follows,
135
+ * for the same reason: on devpoc 66 rows carry a type that disagrees with the collection the id is
136
+ * actually in (29 on dev), and those rows predate the field being declared. So both collections
137
+ * are consulted and the hint only decides which answer wins when both could be true. Trusting the
138
+ * enum bare would misclassify exactly those rows — and misclassifying is how a partner's client
139
+ * ends up seeing an adviser's report.
140
+ *
141
+ * @param {{ distributor_id?: *, e1?: *, e1_type?: string }} client
142
+ * @param {Object} [lookups]
143
+ * @param {(id: *) => Promise<boolean>} [lookups.isEmployee] does this id name an employee-backed user?
144
+ * @param {(id: *) => Promise<boolean>} [lookups.isDistributor] does this id name a real distributor?
145
+ * @returns {Promise<'partner'|'rm_e1'|'direct'>}
146
+ */
147
+ export async function classifyAudience(client, lookups = {}) {
148
+ if (!client) return AUDIENCE.DIRECT;
149
+
150
+ // A client sitting in a partner's book is a partner client, whatever E1 says. This is the
151
+ // strongest signal we have and it is a plain field — house clients were moved to direct by
152
+ // nulling it (dedup, Aug 2026), so `null` genuinely means "not a partner's".
153
+ if (client.distributor_id) return AUDIENCE.PARTNER;
154
+
155
+ const { e1 } = client;
156
+ if (!e1) return AUDIENCE.DIRECT;
157
+
158
+ // Ask both, then let the hint break the tie — never the other way round.
159
+ const [employee, distributor] = await Promise.all([
160
+ safeAsk(lookups.isEmployee, e1),
161
+ safeAsk(lookups.isDistributor, e1),
162
+ ]);
163
+
164
+ if (employee && distributor) {
165
+ return client.e1_type === E1_TYPE_DISTRIBUTOR ? AUDIENCE.PARTNER : AUDIENCE.RM_E1;
166
+ }
167
+ if (employee) return AUDIENCE.RM_E1;
168
+ if (distributor) return AUDIENCE.PARTNER;
169
+
170
+ // An E1 resolving to nobody (deleted user, another account's distributor) is exactly the
171
+ // bad-data case above. Fall back to the reduced-but-safe audience rather than to `full`.
172
+ return AUDIENCE.DIRECT;
173
+ }
174
+
175
+ /**
176
+ * Merge registry overrides over the audience defaults.
177
+ *
178
+ * The registry is the source of truth for WHICH sections exist and whether each is on; the
179
+ * defaults answer while the rows are unseeded. `loadOverrides` is injected rather than imported so
180
+ * this module stays free of the access models — and so a caller with no registry at all (the
181
+ * mobile path has no access snapshot: no roles, no grants) can skip the read entirely without a
182
+ * second code path.
183
+ *
184
+ * @param {string} audience
185
+ * @param {(audience: string) => Promise<Object|null>} [loadOverrides]
186
+ */
187
+ export async function resolveSections(audience, loadOverrides) {
188
+ const base = AUDIENCE_DEFAULTS[audience] || ALL_VISIBLE;
189
+ if (typeof loadOverrides !== "function") return { ...base };
190
+ try {
191
+ const overrides = await loadOverrides(audience);
192
+ return overrides && typeof overrides === "object" ? { ...base, ...overrides } : { ...base };
193
+ } catch {
194
+ return { ...base }; // registry unreadable → the audience default still applies
195
+ }
196
+ }
197
+
198
+ /**
199
+ * The one entry point callers use: classify, resolve, and never throw.
200
+ *
201
+ * @param {Object} client client-ish record (distributor_id, e1, e1_type)
202
+ * @param {Object} [opts]
203
+ * @param {(id: *) => Promise<boolean>} [opts.isEmployee]
204
+ * @param {(id: *) => Promise<boolean>} [opts.isDistributor]
205
+ * @param {(audience: string) => Promise<Object|null>} [opts.loadOverrides]
206
+ * @returns {Promise<{ audience: string, sections: Object }>}
207
+ */
208
+ export async function resolveReportAudience(client, opts = {}) {
209
+ try {
210
+ const audience = await classifyAudience(client, opts);
211
+ return { audience, sections: await resolveSections(audience, opts.loadOverrides) };
212
+ } catch {
213
+ // Fail open. Every caller renders a report from this; none of them should render nothing.
214
+ return { audience: AUDIENCE.FULL, sections: { ...ALL_VISIBLE } };
215
+ }
216
+ }
217
+
218
+ export default {
219
+ AUDIENCE,
220
+ SECTION_KEYS,
221
+ ALL_VISIBLE,
222
+ AUDIENCE_DEFAULTS,
223
+ classifyAudience,
224
+ resolveSections,
225
+ resolveReportAudience,
226
+ };