@i4e/invest4edu-access-core 0.2.0 → 0.4.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,13 +1,15 @@
1
1
  {
2
2
  "name": "@i4e/invest4edu-access-core",
3
- "version": "0.2.0",
4
- "description": "Shared tenant-isolation / access-control primitives (Track 3 D1 keystone + role capabilities) for NeoFindesk backends.",
3
+ "version": "0.4.0",
4
+ "description": "Shared access-control primitives (Track 3: tenant keystone, role capabilities, reportee tree, feature flags) for NeoFindesk backends.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.js",
8
8
  "./tenant-context": "./src/tenant-context.js",
9
9
  "./tenant-plugin": "./src/tenant-plugin.js",
10
- "./role-capabilities": "./src/role-capabilities.js"
10
+ "./role-capabilities": "./src/role-capabilities.js",
11
+ "./reportee-tree": "./src/reportee-tree.js",
12
+ "./access-config": "./src/access-config.js"
11
13
  },
12
14
  "files": [
13
15
  "src",
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Access feature flags — @i4e/invest4edu-access-core (Track 3 master kill-switch).
3
+ *
4
+ * A single place to toggle the newer, riskier access behaviours on/off WITHOUT a code deploy,
5
+ * so a rollout can ship "off" (legacy behaviour) and be flipped on — or killed instantly if it
6
+ * misbehaves. Precedence (lowest → highest):
7
+ *
8
+ * DEFAULT_ACCESS_FLAGS < DB (`accessconfig` collection) < env var
9
+ *
10
+ * DB toggles propagate in ~60s via the backend helper's background refresh (edit in the admin
11
+ * UI, no restart). The env var is the emergency kill: set `ACCESS_FLAG_<UPPER_SNAKE>=off` on the
12
+ * App Service and restart to force a flag regardless of DB.
13
+ *
14
+ * ALL defaults are the SAFE/legacy value — turning a flag on is an explicit, reversible act.
15
+ */
16
+
17
+ export const DEFAULT_ACCESS_FLAGS = Object.freeze({
18
+ // #1 — v2 client-lead reads use the canonical 5-field visibility engine (else legacy lead-scope).
19
+ v2LeadFiveFieldVisibility: false,
20
+ // M1 — send grant/revoke emails via the comms service.
21
+ delegationEmailNotifications: false,
22
+ // H3 — write an access-event when giver-widening actually broadened a delegate's result.
23
+ delegationReadAudit: false,
24
+ // H1 — enable the "acting-as" session path (x-acting-as-giver).
25
+ delegationActingAs: false,
26
+ });
27
+
28
+ const ENV_PREFIX = "ACCESS_FLAG_";
29
+
30
+ /** camelCase flag key → env var name, e.g. v2LeadFiveFieldVisibility → ACCESS_FLAG_V2_LEAD_FIVE_FIELD_VISIBILITY */
31
+ export function flagEnvName(key) {
32
+ return ENV_PREFIX + key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
33
+ }
34
+
35
+ export const ACCESS_FLAG_ENV_NAMES = Object.freeze(
36
+ Object.fromEntries(Object.keys(DEFAULT_ACCESS_FLAGS).map((k) => [k, flagEnvName(k)])),
37
+ );
38
+
39
+ function toBool(v) {
40
+ if (typeof v === "boolean") return v;
41
+ if (v == null || v === "") return undefined;
42
+ return /^(1|true|on|yes|enabled)$/i.test(String(v).trim());
43
+ }
44
+
45
+ /** Read any recognised flags from a process.env-shaped object. Unset vars are omitted. */
46
+ export function readEnvFlags(env = {}) {
47
+ const out = {};
48
+ for (const key of Object.keys(DEFAULT_ACCESS_FLAGS)) {
49
+ const b = toBool(env[flagEnvName(key)]);
50
+ if (b !== undefined) out[key] = b;
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * Merge sources into the effective flag set. Only known keys are honoured.
57
+ * @param {{ dbFlags?: object, envFlags?: object }} sources
58
+ */
59
+ export function resolveFlags({ dbFlags = {}, envFlags = {} } = {}) {
60
+ const out = { ...DEFAULT_ACCESS_FLAGS };
61
+ for (const key of Object.keys(DEFAULT_ACCESS_FLAGS)) {
62
+ const d = toBool(dbFlags ? dbFlags[key] : undefined);
63
+ if (d !== undefined) out[key] = d;
64
+ const e = toBool(envFlags ? envFlags[key] : undefined);
65
+ if (e !== undefined) out[key] = e; // env wins — emergency kill
66
+ }
67
+ return out;
68
+ }
package/src/index.js CHANGED
@@ -10,3 +10,11 @@ export {
10
10
  buildCapabilityMap,
11
11
  resolveCapabilities,
12
12
  } from "./role-capabilities.js";
13
+ export { resolveReporteeUserIds } from "./reportee-tree.js";
14
+ export {
15
+ DEFAULT_ACCESS_FLAGS,
16
+ ACCESS_FLAG_ENV_NAMES,
17
+ flagEnvName,
18
+ readEnvFlags,
19
+ resolveFlags,
20
+ } from "./access-config.js";
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Reportee-tree DFS — @i4e/invest4edu-access-core (Track 3 G3 shared primitive).
3
+ *
4
+ * The one canonical "self + all reportees" traversal, shared by both backends so v1 and v2
5
+ * compose the SAME hierarchy scope. Pure + data-source agnostic: the caller passes a flat
6
+ * employees list `[{ user_id, reporting_manager }]` (v1 feeds the Redis snapshot, v2 the
7
+ * `employees` collection) and one-or-more root user ids (delegation passes `[self, ...givers]`).
8
+ *
9
+ * @param {string|string[]} roots root user id(s)
10
+ * @param {Array<{user_id:*, reporting_manager:*}>} employees flat employee list
11
+ * @returns {string[]} de-duplicated reachable set (roots + all reportees) as STRING ids
12
+ */
13
+ export function resolveReporteeUserIds(roots, employees) {
14
+ const rootIds = (Array.isArray(roots) ? roots : [roots]).filter((id) => id != null).map(String);
15
+
16
+ const byManager = new Map();
17
+ for (const emp of employees || []) {
18
+ if (!emp || !emp.user_id) continue;
19
+ const managerId = String(emp.reporting_manager);
20
+ if (!byManager.has(managerId)) byManager.set(managerId, []);
21
+ byManager.get(managerId).push(String(emp.user_id));
22
+ }
23
+
24
+ const visited = new Set();
25
+ const result = new Set();
26
+ const stack = [...rootIds];
27
+ while (stack.length) {
28
+ const id = stack.pop();
29
+ if (!id || visited.has(id)) continue;
30
+ visited.add(id);
31
+ result.add(id);
32
+ for (const rep of byManager.get(id) || []) stack.push(rep);
33
+ }
34
+ return [...result];
35
+ }