@i4e/invest4edu-access-core 0.40.2 → 0.42.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i4e/invest4edu-access-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
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": {
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"./credits": "./src/credits.js"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
|
-
"test": "node --test test/*.test.mjs test/*.test.js"
|
|
33
|
+
"test": "node --test test/*.test.mjs test/*.test.js",
|
|
34
|
+
"prepublishOnly": "npm test"
|
|
34
35
|
},
|
|
35
36
|
"files": [
|
|
36
37
|
"src",
|
|
@@ -18,10 +18,16 @@ import { buildSideFilter } from "./side-filter.js";
|
|
|
18
18
|
import { FIELD_ACTIONS, evaluateFieldChanges, isPersonalLocked } from "./field-policy.js";
|
|
19
19
|
import { PROFILE_TYPES } from "./settings.js";
|
|
20
20
|
import { goalCreationStamp, goalVisibilityFilter } from "./goals.js";
|
|
21
|
+
import { uccFieldsTouched } from "./ucc-followup.js";
|
|
21
22
|
import { buildCapabilityMap, resolveCapabilities } from "../role-capabilities.js";
|
|
22
23
|
import { CLIENT_APP_ROLE_NAMES } from "../portal-roles.js";
|
|
23
24
|
|
|
24
25
|
const CLIENTS = "distributorclients";
|
|
26
|
+
const UCC_RULES = "uccfieldrules";
|
|
27
|
+
const INVESTOR_PROFILES = "clientinvestorprofiles";
|
|
28
|
+
/** A stored UCC is an alphanumeric code; a failed call leaves BSE's error text there instead. */
|
|
29
|
+
const REAL_UCC = /^[A-Za-z0-9]+$/;
|
|
30
|
+
const UCC_RULES_TTL_MS = 60_000;
|
|
25
31
|
|
|
26
32
|
/**
|
|
27
33
|
* @param {{ getDb: Function, getSettings: Function, getSideContext?: Function,
|
|
@@ -302,5 +308,105 @@ export function createClientProfileService({ getDb, getSettings, getSideContext
|
|
|
302
308
|
}
|
|
303
309
|
}
|
|
304
310
|
|
|
305
|
-
|
|
311
|
+
/* ── UCC follow-up ──────────────────────────────────────────────────────────────────────────
|
|
312
|
+
* A client never pushes to BSE from the app. When their own successful edit changes a detail the
|
|
313
|
+
* product's UCC field rules sync to the UCC, the profile is flagged
|
|
314
|
+
* (`ucc_update_pending { fields, since, last_at, by }`) and the RM is told; backoffice or the RM
|
|
315
|
+
* runs the UCC update from the web, which clears it. Gated by modes.ucc_followup.
|
|
316
|
+
*/
|
|
317
|
+
let uccRulesCache = { at: 0, product: null, doc: null };
|
|
318
|
+
async function uccRulesFor(productCode) {
|
|
319
|
+
if (uccRulesCache.product === productCode && now().getTime() - uccRulesCache.at < UCC_RULES_TTL_MS) return uccRulesCache.doc;
|
|
320
|
+
const doc = await db().collection(UCC_RULES).findOne({ product_code: productCode, is_active: { $ne: false } });
|
|
321
|
+
uccRulesCache = { at: now().getTime(), product: productCode, doc };
|
|
322
|
+
return doc;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** After a SUCCESSFUL write by `userId` that changed `fields` of `clientId`. Never throws. */
|
|
326
|
+
async function noteClientUccEdit({ userId, clientId, fields } = {}) {
|
|
327
|
+
try {
|
|
328
|
+
const settings = getSettings();
|
|
329
|
+
if (!isEvaluated(settings, "ucc_followup") || !userId) return { flagged: false, reason: "mode_off" };
|
|
330
|
+
const cid = toId(clientId);
|
|
331
|
+
if (!cid) return { flagged: false, reason: "no_client" };
|
|
332
|
+
const actor = await resolveActor(userId);
|
|
333
|
+
if (actor?.callerClass !== CALLER_CLASSES.CLIENT) return { flagged: false, reason: "not_client" };
|
|
334
|
+
const touched = uccFieldsTouched(fields, await uccRulesFor(settings.ucc_followup.product_code));
|
|
335
|
+
if (!touched.length) return { flagged: false, reason: "no_ucc_fields" };
|
|
336
|
+
const hasUcc = await db().collection(INVESTOR_PROFILES).findOne(
|
|
337
|
+
{ client_id: cid, "mf_details.ucc_code": { $regex: REAL_UCC } },
|
|
338
|
+
{ projection: { _id: 1 } },
|
|
339
|
+
);
|
|
340
|
+
if (!hasUcc) return { flagged: false, reason: "no_ucc" };
|
|
341
|
+
if (!isEnforced(settings, "ucc_followup")) {
|
|
342
|
+
onAudit({ kind: "ucc_followup", client_id: clientId, fields: touched });
|
|
343
|
+
return { flagged: false, reason: "audit" };
|
|
344
|
+
}
|
|
345
|
+
const at = now();
|
|
346
|
+
await db().collection(CLIENTS).updateOne(
|
|
347
|
+
{ _id: cid },
|
|
348
|
+
{
|
|
349
|
+
$addToSet: { "ucc_update_pending.fields": { $each: touched } },
|
|
350
|
+
$min: { "ucc_update_pending.since": at },
|
|
351
|
+
$set: { "ucc_update_pending.last_at": at, "ucc_update_pending.by": toId(userId) },
|
|
352
|
+
},
|
|
353
|
+
);
|
|
354
|
+
if (emit) {
|
|
355
|
+
Promise.resolve()
|
|
356
|
+
.then(() => emit(settings.events.ucc_update_pending, { client_id: String(clientId), fields: touched }, clientEventSubject(settings, clientId)))
|
|
357
|
+
.catch((err) => onAudit({ kind: "ucc_followup_event_error", client_id: clientId, message: err?.message }));
|
|
358
|
+
}
|
|
359
|
+
return { flagged: true, fields: touched };
|
|
360
|
+
} catch (err) {
|
|
361
|
+
onAudit({ kind: "ucc_followup_error", client_id: clientId, message: err?.message });
|
|
362
|
+
return { flagged: false, reason: "error" };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* May this caller write to this client? A client-app session only to their own records — the
|
|
368
|
+
* same person's profiles under one master; portal callers are scoped by their own rules and
|
|
369
|
+
* always pass here. Gated by modes.own_records_only (audit logs, enforce refuses).
|
|
370
|
+
* @returns {Promise<{ allowed: boolean, reason?: string }>}
|
|
371
|
+
*/
|
|
372
|
+
async function callerMayWriteClient({ userId, clientId } = {}) {
|
|
373
|
+
const settings = getSettings();
|
|
374
|
+
if (!isEvaluated(settings, "own_records_only") || !userId || !clientId) return { allowed: true };
|
|
375
|
+
let actor;
|
|
376
|
+
try {
|
|
377
|
+
actor = await resolveActor(userId);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
onAudit({ kind: "own_records_error", client_id: clientId, message: err?.message });
|
|
380
|
+
return { allowed: true };
|
|
381
|
+
}
|
|
382
|
+
if (actor?.callerClass !== CALLER_CLASSES.CLIENT) return { allowed: true };
|
|
383
|
+
let own = [];
|
|
384
|
+
try {
|
|
385
|
+
const user = await db().collection("users").findOne({ _id: toId(userId) }, { projection: { client_id: 1 } });
|
|
386
|
+
if (user?.client_id) {
|
|
387
|
+
const master = await db().collection("masterchildclients").findOne({ child_client_ids: user.client_id }, { projection: { child_client_ids: 1 } });
|
|
388
|
+
own = (master?.child_client_ids?.length ? master.child_client_ids : [user.client_id]).map(String);
|
|
389
|
+
}
|
|
390
|
+
} catch (err) {
|
|
391
|
+
// A client session whose records cannot be read must not write anywhere.
|
|
392
|
+
onAudit({ kind: "own_records_error", client_id: clientId, message: err?.message });
|
|
393
|
+
return { allowed: !isEnforced(settings, "own_records_only"), reason: "unresolved" };
|
|
394
|
+
}
|
|
395
|
+
if (own.includes(String(clientId))) return { allowed: true };
|
|
396
|
+
onAudit({ kind: "own_records_only", client_id: clientId, user_id: String(userId) });
|
|
397
|
+
return { allowed: !isEnforced(settings, "own_records_only"), reason: "not_own_record" };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The UCC was updated: nothing is pending any more. Never throws. */
|
|
401
|
+
async function clearUccPending(clientId) {
|
|
402
|
+
try {
|
|
403
|
+
const cid = toId(clientId);
|
|
404
|
+
if (!cid) return;
|
|
405
|
+
await db().collection(CLIENTS).updateOne({ _id: cid, ucc_update_pending: { $exists: true } }, { $unset: { ucc_update_pending: "" } });
|
|
406
|
+
} catch (err) {
|
|
407
|
+
onAudit({ kind: "ucc_clear_error", client_id: clientId, message: err?.message });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return Object.freeze({ findActiveProfilesByPan, findExisting, attach, attachUser, resolveActor, sideFilterFor, evaluatePersonalEdit, goalContextFor, noteClientUccEdit, clearUccPending, callerMayWriteClient });
|
|
306
412
|
}
|
|
@@ -73,6 +73,7 @@ export const DEFAULT_CLIENT_PROFILE_SETTINGS = deepFreeze({
|
|
|
73
73
|
mf_one_investor: MODES.OFF,
|
|
74
74
|
detail_scope: MODES.OFF,
|
|
75
75
|
ucc_followup: MODES.OFF,
|
|
76
|
+
own_records_only: MODES.OFF,
|
|
76
77
|
},
|
|
77
78
|
corporate_distributor_types: [],
|
|
78
79
|
house_distributor_codes: [],
|
|
@@ -186,6 +187,7 @@ export const CLIENT_PROFILE_SETTING_DESCRIPTIONS = Object.freeze({
|
|
|
186
187
|
"modes.goal_approval": "Hold partner-added goals until the client approves them.",
|
|
187
188
|
"modes.resolve_by_pan": "Show an existing client instead of a duplicate error when a PAN is found.",
|
|
188
189
|
"modes.mf_one_investor": "Refuse a second MF investor for a person who already has one (D9).",
|
|
190
|
+
"modes.own_records_only": "A client-app session may write only its own records (one person under one master); anything else is refused.",
|
|
189
191
|
"modes.ucc_followup": "Flag a profile for a UCC update when its client changes UCC details in the app (never pushed by the client).",
|
|
190
192
|
"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).",
|
|
191
193
|
"modes.linked_visibility": "Clients reached only through a secondary mapping or a linked user appear in lists, as the limited card.",
|