@i4e/invest4edu-access-core 0.32.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
@@ -1,126 +1,126 @@
1
- # @i4e/invest4edu-access-core
2
-
3
- Shared tenant-isolation / access-control primitives for the NeoFindesk backends
4
- (nfd-api-node v1 + nfd-api-node-v2). The **D1 keystone** of the access-management epic:
5
- central, unforgettable `account_id` scoping.
6
-
7
- Both backends are ESM, so this package ships plain ESM source — no build step.
8
-
9
- ## 0.32.0 — BREAKING, and it fails silently
10
-
11
- `resolveReporteeUserIds` **no longer reads `employees.reporting_manager` as a user id.** It
12
- reads it only as the manager's employee `_id`, which is the shape BRI-973 migrates the field
13
- to. This is a breaking change on a 0.x minor, so read the two notes below before upgrading a
14
- consumer.
15
-
16
- **1. Against un-migrated data it returns zero reportees, not an error.** The dual-shape
17
- widening that carried the rollout is gone. A manager whose rows have not been migrated
18
- resolves to a self-only scope, and because this function feeds record visibility in
19
- nfd-api-node and the lead access scope in nfd-api-node-v2, that reads as *"this manager has
20
- no reportees"* — nothing thrown, nothing logged, just a smaller answer.
21
-
22
- So the deploy order is **migrate → verify → bump**, not the other way round. Run
23
- nfd-api-node's `migrations/verify-reporting-manager-to-employee-id.js` and get a clean
24
- verdict before the first instance carrying 0.32.0 serves traffic. In a rolling deploy that
25
- window is real, so the migration must be complete rather than merely started.
26
-
27
- Under npm's 0.x caret rules `^0.31.0` will not resolve to 0.32.0, so no consumer is dragged
28
- across by an install — the ordering risk is entirely a human one.
29
-
30
- **2. Callers must pass the FULL employee list.** An `_id` reference only resolves if the row
31
- carrying it is present, so a pre-filtered list (`{ status: 1 }`, an account scope, anything)
32
- drops link rows — and what you lose is not the filtered person but everyone beneath them.
33
- Filter the result instead. `resolveReporteeUserIds` warns once when handed a list in which no
34
- row carries an `_id` at all, which is the one case where the answer is confidently wrong
35
- rather than merely narrow.
36
-
37
- 0.32.0 also moves the entity/role vocabulary — `ENTITY`, `ENTITY_STATUS`,
38
- `isEntityLoginAllowed`, `conflictingEntityFor`, `ROLE_TYPE_PRIORITY`,
39
- `CLIENT_APP_ROLE_NAMES` — into this package. It was previously written out three times
40
- (nfd-api-node, nfd-api-node-v2, nfdui-nextjs) and held together by parity tests.
41
-
42
- ## Install
43
-
44
- ```
45
- npm install @i4e/invest4edu-access-core
46
- ```
47
-
48
- ## Usage
49
-
50
- **1. Carry identity per request** (in the auth middleware, JWT path only):
51
-
52
- ```js
53
- import als from "@i4e/invest4edu-access-core/tenant-context";
54
- // after verifying the JWT:
55
- return als.run({ account_id, userId, roles }, () => next());
56
- ```
57
-
58
- **2. Enforce on tenant-scoped models:**
59
-
60
- ```js
61
- import { tenantPlugin } from "@i4e/invest4edu-access-core";
62
- schema.plugin(tenantPlugin); // before mongoose.model(...)
63
- ```
64
-
65
- The plugin injects `account_id` from the request's ALS store into the **filter** of every
66
- operation that selects existing documents:
67
-
68
- | | ops | switch |
69
- |---|---|---|
70
- | reads | `find` `findOne` `countDocuments` `distinct` | `TENANT_ENFORCEMENT` / `accessconfig.tenantEnforcement` |
71
- | writes | `updateOne` `updateMany` `replaceOne` `deleteOne` `deleteMany` `findOneAndUpdate` `findOneAndReplace` `findOneAndDelete` | `TENANT_ENFORCEMENT_WRITES` / `accessconfig.tenantEnforcementWrites` |
72
-
73
- Each switch is an independent ladder — `off` → `warn` → `enforce` — and **both default to `off`**,
74
- so installing the plugin is a literal no-op until you arm it. Reads and writes are separate
75
- because the blast radius is not comparable: a read that gains a filter returns less data, while a
76
- write that gains a filter silently modifies **nothing** and still reports success. Arming reads
77
- must never arm writes by surprise.
78
-
79
- | mode | identity present | identity absent |
80
- |---|---|---|
81
- | `off` (default) | no-op | no-op |
82
- | `warn` | reads: inject `account_id` · writes: **unchanged** | log `[tenant] identity-less …`, do **not** scope |
83
- | `enforce` | inject `account_id` | **throw** (fail-closed) |
84
-
85
- `warn` deliberately leaves writes alone. `warn` means "tell me what `enforce` would do", and for a
86
- destructive operation that promise is only kept by changing nothing — injecting a filter under a
87
- mode named `warn` would turn live `UPDATE`s into silent no-ops, the exact failure burn-in exists to
88
- catch. What you need before promoting is which writes run identity-less, and that is logged.
89
-
90
- Precedence per ladder: env var (if valid) → DB value → `off`. An unrecognised value is reported
91
- once and ignored rather than treated as `off`, so a typo cannot silently disarm enforcement.
92
-
93
- **3. Bypass for legitimately cross-tenant operations** (caches, migrations, scripts):
94
-
95
- ```js
96
- import { runAsSystem } from "@i4e/invest4edu-access-core";
97
- await runAsSystem(() => Employee.find({ status: 1 })); // whole scope
98
- Model.find(q).setOptions({ skipTenant: true }); // one query
99
- ```
100
-
101
- ## Exports
102
-
103
- - `als` (default of `/tenant-context`) — the AsyncLocalStorage instance
104
- - `runWithTenant(store, fn)`, `runAsSystem(fn)`, `getTenantStore()`
105
- - `tenantPlugin` (default of `/tenant-plugin`) — the Mongoose plugin
106
- - `setTenantMode(m)` / `getTenantMode()` — the read ladder
107
- - `setTenantWriteMode(m)` / `getTenantWriteMode()` — the write ladder
108
- - `resolveReporteeUserIds(roots, employees, opts)` (`/reportee-tree`) — self + all reportees
109
- - `ENTITY`, `ENTITY_STATUS`, `isEntityLoginAllowed`, `entityInactiveMessage`,
110
- `checkPortalEntityAccess` (`/entity-status`) — which entities permit a login
111
- - `conflictingEntityFor`, `entityConflictMessage`, `CONFLICTING_ENTITIES`
112
- (`/user-entity-link`) — which hats may coexist on one person
113
- - `ROLE_TYPE_PRIORITY`, `CLIENT_APP_ROLE_NAMES`, `pickPortalRoleName` (`/portal-roles`) —
114
- which role names a portal session
115
-
116
- ## Not covered
117
- **Aggregation pipelines** — add an explicit `{ $match: { account_id } }` stage.
118
-
119
- **Inserts** (`save`, `create`, `insertMany`) — and they cannot be. They carry no filter, so there
120
- is nothing to constrain; putting `account_id` *on* a new document is the caller's job. The plugin
121
- only governs which **existing** documents an operation may reach.
122
-
123
- `estimatedDocumentCount` takes no filter either, so it is not hooked.
124
-
125
- ## Compatibility
126
- Node ≥ 18 (AsyncLocalStorage). `mongoose` ≥ 6 is an optional peer (only the plugin needs it).
1
+ # @i4e/invest4edu-access-core
2
+
3
+ Shared tenant-isolation / access-control primitives for the NeoFindesk backends
4
+ (nfd-api-node v1 + nfd-api-node-v2). The **D1 keystone** of the access-management epic:
5
+ central, unforgettable `account_id` scoping.
6
+
7
+ Both backends are ESM, so this package ships plain ESM source — no build step.
8
+
9
+ ## 0.32.0 — BREAKING, and it fails silently
10
+
11
+ `resolveReporteeUserIds` **no longer reads `employees.reporting_manager` as a user id.** It
12
+ reads it only as the manager's employee `_id`, which is the shape BRI-973 migrates the field
13
+ to. This is a breaking change on a 0.x minor, so read the two notes below before upgrading a
14
+ consumer.
15
+
16
+ **1. Against un-migrated data it returns zero reportees, not an error.** The dual-shape
17
+ widening that carried the rollout is gone. A manager whose rows have not been migrated
18
+ resolves to a self-only scope, and because this function feeds record visibility in
19
+ nfd-api-node and the lead access scope in nfd-api-node-v2, that reads as *"this manager has
20
+ no reportees"* — nothing thrown, nothing logged, just a smaller answer.
21
+
22
+ So the deploy order is **migrate → verify → bump**, not the other way round. Run
23
+ nfd-api-node's `migrations/verify-reporting-manager-to-employee-id.js` and get a clean
24
+ verdict before the first instance carrying 0.32.0 serves traffic. In a rolling deploy that
25
+ window is real, so the migration must be complete rather than merely started.
26
+
27
+ Under npm's 0.x caret rules `^0.31.0` will not resolve to 0.32.0, so no consumer is dragged
28
+ across by an install — the ordering risk is entirely a human one.
29
+
30
+ **2. Callers must pass the FULL employee list.** An `_id` reference only resolves if the row
31
+ carrying it is present, so a pre-filtered list (`{ status: 1 }`, an account scope, anything)
32
+ drops link rows — and what you lose is not the filtered person but everyone beneath them.
33
+ Filter the result instead. `resolveReporteeUserIds` warns once when handed a list in which no
34
+ row carries an `_id` at all, which is the one case where the answer is confidently wrong
35
+ rather than merely narrow.
36
+
37
+ 0.32.0 also moves the entity/role vocabulary — `ENTITY`, `ENTITY_STATUS`,
38
+ `isEntityLoginAllowed`, `conflictingEntityFor`, `ROLE_TYPE_PRIORITY`,
39
+ `CLIENT_APP_ROLE_NAMES` — into this package. It was previously written out three times
40
+ (nfd-api-node, nfd-api-node-v2, nfdui-nextjs) and held together by parity tests.
41
+
42
+ ## Install
43
+
44
+ ```
45
+ npm install @i4e/invest4edu-access-core
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ **1. Carry identity per request** (in the auth middleware, JWT path only):
51
+
52
+ ```js
53
+ import als from "@i4e/invest4edu-access-core/tenant-context";
54
+ // after verifying the JWT:
55
+ return als.run({ account_id, userId, roles }, () => next());
56
+ ```
57
+
58
+ **2. Enforce on tenant-scoped models:**
59
+
60
+ ```js
61
+ import { tenantPlugin } from "@i4e/invest4edu-access-core";
62
+ schema.plugin(tenantPlugin); // before mongoose.model(...)
63
+ ```
64
+
65
+ The plugin injects `account_id` from the request's ALS store into the **filter** of every
66
+ operation that selects existing documents:
67
+
68
+ | | ops | switch |
69
+ |---|---|---|
70
+ | reads | `find` `findOne` `countDocuments` `distinct` | `TENANT_ENFORCEMENT` / `accessconfig.tenantEnforcement` |
71
+ | writes | `updateOne` `updateMany` `replaceOne` `deleteOne` `deleteMany` `findOneAndUpdate` `findOneAndReplace` `findOneAndDelete` | `TENANT_ENFORCEMENT_WRITES` / `accessconfig.tenantEnforcementWrites` |
72
+
73
+ Each switch is an independent ladder — `off` → `warn` → `enforce` — and **both default to `off`**,
74
+ so installing the plugin is a literal no-op until you arm it. Reads and writes are separate
75
+ because the blast radius is not comparable: a read that gains a filter returns less data, while a
76
+ write that gains a filter silently modifies **nothing** and still reports success. Arming reads
77
+ must never arm writes by surprise.
78
+
79
+ | mode | identity present | identity absent |
80
+ |---|---|---|
81
+ | `off` (default) | no-op | no-op |
82
+ | `warn` | reads: inject `account_id` · writes: **unchanged** | log `[tenant] identity-less …`, do **not** scope |
83
+ | `enforce` | inject `account_id` | **throw** (fail-closed) |
84
+
85
+ `warn` deliberately leaves writes alone. `warn` means "tell me what `enforce` would do", and for a
86
+ destructive operation that promise is only kept by changing nothing — injecting a filter under a
87
+ mode named `warn` would turn live `UPDATE`s into silent no-ops, the exact failure burn-in exists to
88
+ catch. What you need before promoting is which writes run identity-less, and that is logged.
89
+
90
+ Precedence per ladder: env var (if valid) → DB value → `off`. An unrecognised value is reported
91
+ once and ignored rather than treated as `off`, so a typo cannot silently disarm enforcement.
92
+
93
+ **3. Bypass for legitimately cross-tenant operations** (caches, migrations, scripts):
94
+
95
+ ```js
96
+ import { runAsSystem } from "@i4e/invest4edu-access-core";
97
+ await runAsSystem(() => Employee.find({ status: 1 })); // whole scope
98
+ Model.find(q).setOptions({ skipTenant: true }); // one query
99
+ ```
100
+
101
+ ## Exports
102
+
103
+ - `als` (default of `/tenant-context`) — the AsyncLocalStorage instance
104
+ - `runWithTenant(store, fn)`, `runAsSystem(fn)`, `getTenantStore()`
105
+ - `tenantPlugin` (default of `/tenant-plugin`) — the Mongoose plugin
106
+ - `setTenantMode(m)` / `getTenantMode()` — the read ladder
107
+ - `setTenantWriteMode(m)` / `getTenantWriteMode()` — the write ladder
108
+ - `resolveReporteeUserIds(roots, employees, opts)` (`/reportee-tree`) — self + all reportees
109
+ - `ENTITY`, `ENTITY_STATUS`, `isEntityLoginAllowed`, `entityInactiveMessage`,
110
+ `checkPortalEntityAccess` (`/entity-status`) — which entities permit a login
111
+ - `conflictingEntityFor`, `entityConflictMessage`, `CONFLICTING_ENTITIES`
112
+ (`/user-entity-link`) — which hats may coexist on one person
113
+ - `ROLE_TYPE_PRIORITY`, `CLIENT_APP_ROLE_NAMES`, `pickPortalRoleName` (`/portal-roles`) —
114
+ which role names a portal session
115
+
116
+ ## Not covered
117
+ **Aggregation pipelines** — add an explicit `{ $match: { account_id } }` stage.
118
+
119
+ **Inserts** (`save`, `create`, `insertMany`) — and they cannot be. They carry no filter, so there
120
+ is nothing to constrain; putting `account_id` *on* a new document is the caller's job. The plugin
121
+ only governs which **existing** documents an operation may reach.
122
+
123
+ `estimatedDocumentCount` takes no filter either, so it is not hooked.
124
+
125
+ ## Compatibility
126
+ Node ≥ 18 (AsyncLocalStorage). `mongoose` ≥ 6 is an optional peer (only the plugin needs it).
package/package.json CHANGED
@@ -1,56 +1,58 @@
1
- {
2
- "name": "@i4e/invest4edu-access-core",
3
- "version": "0.32.0",
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
- "type": "module",
6
- "exports": {
7
- ".": "./src/index.js",
8
- "./tenant-context": "./src/tenant-context.js",
9
- "./tenant-plugin": "./src/tenant-plugin.js",
10
- "./role-capabilities": "./src/role-capabilities.js",
11
- "./reportee-tree": "./src/reportee-tree.js",
12
- "./entity-status": "./src/entity-status.js",
13
- "./user-entity-link": "./src/user-entity-link.js",
14
- "./portal-roles": "./src/portal-roles.js",
15
- "./access-config": "./src/access-config.js",
16
- "./access-schema": "./src/access-schema.js",
17
- "./access-resolver": "./src/access-resolver.js",
18
- "./visible-when": "./src/visible-when.js",
19
- "./entitlement": "./src/entitlement.js",
20
- "./entitlement-schema": "./src/entitlement-schema.js",
21
- "./grid-schema": "./src/grid-schema.js",
22
- "./route-features": "./src/route-features.js",
23
- "./subscription-lifecycle": "./src/subscription-lifecycle.js",
24
- "./entitlement-store": "./src/entitlement-store.js",
25
- "./proration": "./src/proration.js",
26
- "./credits": "./src/credits.js"
27
- },
28
- "scripts": {
29
- "test": "node --test test/*.test.mjs test/*.test.js"
30
- },
31
- "files": [
32
- "src",
33
- "README.md"
34
- ],
35
- "engines": {
36
- "node": ">=18"
37
- },
38
- "sideEffects": false,
39
- "keywords": [
40
- "nfd",
41
- "access-control",
42
- "tenant-isolation",
43
- "mongoose",
44
- "async-local-storage"
45
- ],
46
- "peerDependencies": {
47
- "mongoose": ">=6"
48
- },
49
- "peerDependenciesMeta": {
50
- "mongoose": {
51
- "optional": true
52
- }
53
- },
54
- "license": "UNLICENSED",
55
- "private": false
56
- }
1
+ {
2
+ "name": "@i4e/invest4edu-access-core",
3
+ "version": "0.34.0",
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
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./tenant-context": "./src/tenant-context.js",
9
+ "./tenant-plugin": "./src/tenant-plugin.js",
10
+ "./role-capabilities": "./src/role-capabilities.js",
11
+ "./reportee-tree": "./src/reportee-tree.js",
12
+ "./entity-status": "./src/entity-status.js",
13
+ "./user-entity-link": "./src/user-entity-link.js",
14
+ "./portal-roles": "./src/portal-roles.js",
15
+ "./access-config": "./src/access-config.js",
16
+ "./access-schema": "./src/access-schema.js",
17
+ "./access-resolver": "./src/access-resolver.js",
18
+ "./visible-when": "./src/visible-when.js",
19
+ "./entitlement": "./src/entitlement.js",
20
+ "./entitlement-schema": "./src/entitlement-schema.js",
21
+ "./grid-schema": "./src/grid-schema.js",
22
+ "./route-features": "./src/route-features.js",
23
+ "./route-screen": "./src/route-screen.js",
24
+ "./report-audience": "./src/report-audience.js",
25
+ "./subscription-lifecycle": "./src/subscription-lifecycle.js",
26
+ "./entitlement-store": "./src/entitlement-store.js",
27
+ "./proration": "./src/proration.js",
28
+ "./credits": "./src/credits.js"
29
+ },
30
+ "scripts": {
31
+ "test": "node --test test/*.test.mjs test/*.test.js"
32
+ },
33
+ "files": [
34
+ "src",
35
+ "README.md"
36
+ ],
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "sideEffects": false,
41
+ "keywords": [
42
+ "nfd",
43
+ "access-control",
44
+ "tenant-isolation",
45
+ "mongoose",
46
+ "async-local-storage"
47
+ ],
48
+ "peerDependencies": {
49
+ "mongoose": ">=6"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "mongoose": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "license": "UNLICENSED",
57
+ "private": false
58
+ }
@@ -1,68 +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
- }
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
+ }