@i4e/invest4edu-access-core 0.42.1 → 0.43.1

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@i4e/invest4edu-access-core",
3
- "version": "0.42.1",
3
+ "version": "0.43.1",
4
4
  "description": "Shared access-control primitives for NeoFindesk: tenant keystone, role capabilities, reportee tree, entity/role vocabulary, mobile app identity, feature flags, and the unified access engine (registry schema, snapshot resolver, visibleWhen).",
5
5
  "type": "module",
6
6
  "exports": {
@@ -37,7 +37,7 @@ const UCC_RULES_TTL_MS = 60_000;
37
37
  * distributors); without it an employee is never treated as already seeing a client.
38
38
  * `toId` converts ids to the stored type (ObjectId); identity by default.
39
39
  */
40
- export function createClientProfileService({ getDb, getSettings, getSideContext = () => ({}), getVisibilitySets = null, getFieldPolicyIndex = () => new Map(), toId = (v) => v, onAudit = () => {}, emit = null, now = () => new Date() }) {
40
+ export function createClientProfileService({ getDb, getSettings, getSideContext = () => ({}), getVisibilitySets = null, getFieldPolicyIndex = () => new Map(), toId = (v) => v, onAudit = () => {}, emit = null, now = () => new Date(), getSessionContext = () => null }) {
41
41
  const db = () => {
42
42
  const handle = getDb?.();
43
43
  if (!handle) throw new Error("client-profile service: no database handle");
@@ -56,10 +56,10 @@ export function createClientProfileService({ getDb, getSettings, getSideContext
56
56
  * `mode` tells the caller whether to act on it: "enforce" → show the existing client instead
57
57
  * of creating; "audit" → carry on as before, the match was only reported.
58
58
  */
59
- async function findExisting({ pan, profileType, session }) {
59
+ async function findExisting({ pan, profileType, distributorId = null, session }) {
60
60
  const settings = getSettings();
61
61
  if (!isEvaluated(settings, "resolve_by_pan")) return { profile: null, mode: "off" };
62
- const profile = pickProfileOfType(await findActiveProfilesByPan(pan, { session }), profileType);
62
+ const profile = pickProfileOfType(await findActiveProfilesByPan(pan, { session }), profileType, { distributorId, settings, ctx: getSideContext() });
63
63
  const mode = isEnforced(settings, "resolve_by_pan") ? "enforce" : "audit";
64
64
  if (profile && mode === "audit") onAudit({ kind: "resolve_by_pan", client_id: profile._id, profile_type: profileType });
65
65
  return { profile, mode };
@@ -144,7 +144,15 @@ export function createClientProfileService({ getDb, getSettings, getSideContext
144
144
  if (!id) return null;
145
145
  const user = await db().collection("users").findOne({ _id: id }, { session, projection: { distributor_id: 1, client_id: 1, employee_id: 1, full_record_access: 1 } });
146
146
  if (!user) return null;
147
- const roles = await roleNamesOf(id, session);
147
+ // The SESSION decides, not the stored roles: a person who is both a partner and a client has one
148
+ // user record, and their app login is pinned to the client role in its token while their
149
+ // portal login carries the partner role. The backend injects `getSessionContext` (its request's
150
+ // tenant context: the verified token's userId + roles — getTenantStore from
151
+ // @i4e/invest4edu-access-core/tenant-context, kept out of this module so browser code that
152
+ // imports client-profile never pulls in async_hooks); used when it is the user being resolved.
153
+ const ctx = getSessionContext?.() ?? null;
154
+ const sessionRoles = ctx && ctx.userId != null && String(ctx.userId) === String(userId) && Array.isArray(ctx.roles) && ctx.roles.length ? ctx.roles : null;
155
+ const roles = sessionRoles ?? await roleNamesOf(id, session);
148
156
  const capabilityMap = buildCapabilityMap(await db().collection("rolecapabilities").find({}, { session }).toArray());
149
157
  const isClientApp = Boolean(user.client_id) && roles.length > 0 && roles.every((r) => CLIENT_APP_ROLE_NAMES.includes(r));
150
158
  return {
@@ -65,9 +65,25 @@ export function profileTypeForCaller(callerDistributor, settings) {
65
65
  return profileTypeFor(callerDistributor, settings);
66
66
  }
67
67
 
68
+ /** Corporate profiles are one per corporate firm and never shared (settings.corporate). */
69
+ function corporateSiloed(settings) {
70
+ return settings?.corporate?.one_profile_per_distributor !== false;
71
+ }
72
+
73
+ /** The side that owns a profile: its primary mapping, else its distributor_id. */
74
+ function primarySideOfProfile(profile, ctx) {
75
+ return sideKeyOf((profile?.distributor_mappings ?? []).find((m) => m?.is_primary)?.distributor_id ?? profile?.distributor_id, ctx);
76
+ }
77
+
68
78
  /** Of a person's active profiles, the one of this type (oldest first if the data has duplicates). */
69
- export function pickProfileOfType(profiles = [], profileType) {
70
- const ofType = profiles.filter((p) => (p.profile_type ?? PROFILE_TYPES.MAIN) === profileType);
79
+ export function pickProfileOfType(profiles = [], profileType, { distributorId = null, settings, ctx } = {}) {
80
+ let ofType = profiles.filter((p) => (p.profile_type ?? PROFILE_TYPES.MAIN) === profileType);
81
+ // A corporate profile belongs to ONE corporate firm: only that firm's own profile is "the existing
82
+ // one". Another firm (or a caller with no firm) finds nothing and creates its own.
83
+ if (profileType === PROFILE_TYPES.CORPORATE && corporateSiloed(settings)) {
84
+ const side = distributorId ? sideKeyOf(distributorId, ctx) : null;
85
+ ofType = side ? ofType.filter((p) => primarySideOfProfile(p, ctx) === side) : [];
86
+ }
71
87
  ofType.sort((a, b) => new Date(a.created_date ?? 0) - new Date(b.created_date ?? 0));
72
88
  return ofType[0] ?? null;
73
89
  }
@@ -90,9 +106,13 @@ export function attachDecision({ profile, actor = {}, via, at = new Date(), sett
90
106
  if (actor.callerClass === CALLER_CLASSES.PARTNER) {
91
107
  const side = sideKeyOf(actor.distributorId, ctx);
92
108
  if (side === DIRECT_SIDE) return { kind: "none", reason: "partner_without_firm" };
93
- const primarySide = sideKeyOf((profile.distributor_mappings ?? []).find((m) => m?.is_primary)?.distributor_id ?? profile.distributor_id, ctx);
109
+ const primarySide = primarySideOfProfile(profile, ctx);
94
110
  const mapped = side === primarySide || (profile.distributor_mappings ?? []).some((m) => m && m.status !== "inactive" && sideKeyOf(m.distributor_id, ctx) === side);
95
111
  if (mapped) return { kind: "none", reason: "already_mapped" };
112
+ // A corporate profile is its firm's alone: no other firm is ever added to it.
113
+ if ((profile.profile_type ?? PROFILE_TYPES.MAIN) === PROFILE_TYPES.CORPORATE && corporateSiloed(settings)) {
114
+ return { kind: "none", reason: "corporate_silo" };
115
+ }
96
116
  return {
97
117
  kind: "mapping",
98
118
  reason: "new_side",
@@ -77,6 +77,12 @@ export const DEFAULT_CLIENT_PROFILE_SETTINGS = deepFreeze({
77
77
  },
78
78
  corporate_distributor_types: [],
79
79
  house_distributor_codes: [],
80
+ corporate: {
81
+ // Each corporate distributor holds its OWN profile of a person (one per corporate firm), and
82
+ // works on it alone: a PAN lookup never surfaces another firm's corporate profile, and no
83
+ // other firm is ever attached to one.
84
+ one_profile_per_distributor: true,
85
+ },
80
86
  primary: {
81
87
  lock_on_first_order: true,
82
88
  ignore_order_statuses: [],
@@ -192,6 +198,7 @@ export const CLIENT_PROFILE_SETTING_DESCRIPTIONS = Object.freeze({
192
198
  "modes.detail_scope": "A client's full record by id only for callers whose client list includes them; everyone else gets the limited card (R15).",
193
199
  "modes.linked_visibility": "Clients reached only through a secondary mapping or a linked user appear in lists, as the limited card.",
194
200
  corporate_distributor_types: "distributors.distributor_type values whose clients get a corporate profile.",
201
+ "corporate.one_profile_per_distributor": "Each corporate firm has its own, siloed profile of a person: no cross-firm lookup or attach.",
195
202
  house_distributor_codes: "Distributor codes treated as direct (InvestValue's own house distributors).",
196
203
  "primary.lock_on_first_order": "The first non-ignored order locks the primary distributor.",
197
204
  "primary.ignore_order_statuses": "Order statuses that do not count as first business (e.g. cancelled).",