@i4e/invest4edu-access-core 0.8.0 → 0.10.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.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Shared access-control primitives for NeoFindesk: tenant keystone, role capabilities, reportee tree, feature flags, and the unified access engine (registry schema, snapshot resolver, visibleWhen).",
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,7 +15,8 @@
15
15
  "./visible-when": "./src/visible-when.js",
16
16
  "./entitlement": "./src/entitlement.js",
17
17
  "./entitlement-schema": "./src/entitlement-schema.js",
18
- "./grid-schema": "./src/grid-schema.js"
18
+ "./grid-schema": "./src/grid-schema.js",
19
+ "./route-features": "./src/route-features.js"
19
20
  },
20
21
  "scripts": {
21
22
  "test": "node --test test/"
@@ -12,7 +12,7 @@
12
12
  * Design: TDD-unified-access-and-subscription.md §4
13
13
  */
14
14
 
15
- import { parentCodeOf } from "./access-schema.js";
15
+ import { parentCodeOf, PRODUCT_UI_MODES } from "./access-schema.js";
16
16
 
17
17
  /** Reasons a feature can be locked, surfaced so the UI can distinguish upsell from hard-deny. */
18
18
  export const ENTITLEMENT_REASONS = Object.freeze({
@@ -219,6 +219,12 @@ export function resolveAccessSnapshot({
219
219
  actions: [],
220
220
  entitlement,
221
221
  tracking: !!f.tracking_enabled,
222
+ // How this SCREEN should present product-keyed things it renders — distinct from
223
+ // `products` above, which is *reachability* ("this screen exists only for these
224
+ // products"). The two are independent: Open Orders is product-agnostic yet renders
225
+ // product tabs that must be filtered, so the behaviour cannot be derived from the
226
+ // constraint. Default 'none' keeps every existing screen rendering exactly as today.
227
+ product_ui: PRODUCT_UI_MODES.includes(f.product_ui) ? f.product_ui : "none",
222
228
  };
223
229
 
224
230
  if (f.feature_type === "screen") allowedScreens.push(f);
@@ -23,6 +23,27 @@ export const REGISTRY_STATUSES = Object.freeze(["active", "dormant", "retired"])
23
23
  /** Per-feature enforcement ladder (mirrors the platform's off→warn→enforce doctrine). */
24
24
  export const ROLLOUT_MODES = Object.freeze(["off", "warn", "enforce"]);
25
25
 
26
+ /**
27
+ * How a SCREEN presents the product-keyed things it renders — product tiles, product tabs, the
28
+ * product column on Add Order, scheme lists.
29
+ *
30
+ * Deliberately separate from `products[]` on the same feature, which is *reachability* ("this
31
+ * screen exists only for these products"). The two are independent and neither implies the other:
32
+ * Open Orders is product-agnostic (`products: []`, it serves every product) yet renders product
33
+ * tabs that must be filtered; Product Setup is equally agnostic and should hide nothing. Same
34
+ * reachability, opposite presentation — so this cannot be derived, it has to be declared.
35
+ *
36
+ * none render everything. The default, so every existing screen is unchanged.
37
+ * filter hide product-keyed items outside the caller's scope. For transactional screens.
38
+ * readonly show everything, but out-of-scope items are not interactive. For configuration
39
+ * screens, where hiding a product reads as "the catalogue is broken" rather than as
40
+ * "you may not touch this".
41
+ *
42
+ * This is presentation only. The server-side gate is what actually refuses a write — hiding a
43
+ * tile has never stopped a POST.
44
+ */
45
+ export const PRODUCT_UI_MODES = Object.freeze(["none", "filter", "readonly"]);
46
+
26
47
  /** Grant effect. `deny` at the user level replaces the old `revoked_pages`. */
27
48
  export const GRANT_EFFECTS = Object.freeze(["allow", "deny"]);
28
49
 
@@ -96,6 +117,7 @@ export const ACCESS_FEATURE_DEF = Object.freeze({
96
117
  status: { type: "String", enum: REGISTRY_STATUSES, default: "active", index: true },
97
118
  kill_switch: { type: "String", enum: ["on", "off"], default: "off" },
98
119
  rollout_mode: { type: "String", enum: ROLLOUT_MODES, default: "off" },
120
+ product_ui: { type: "String", enum: PRODUCT_UI_MODES, default: "none" },
99
121
 
100
122
  // Survives an entitlement block (login, billing/checkout, profile) — see design §7.4.
101
123
  always_available: { type: "Boolean", default: false },
@@ -139,6 +161,7 @@ export default {
139
161
  FEATURE_TYPES,
140
162
  REGISTRY_STATUSES,
141
163
  ROLLOUT_MODES,
164
+ PRODUCT_UI_MODES,
142
165
  GRANT_EFFECTS,
143
166
  GRANT_SUBJECTS,
144
167
  FEATURE_CODE_PATTERN,
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Route → feature map: the mechanism, shared by both backends.
3
+ *
4
+ * Gates used to be written into each route. That works, and it has one fatal property: **a route
5
+ * with no gate looks exactly like a route that needs none.** Forgetting is invisible, and auditing
6
+ * means scraping source for string literals. With a map, the absence is a missing ROW.
7
+ *
8
+ * ── The rule this exists to protect ──────────────────────────────────────────────────────────
9
+ * **The thing being protected decides what protects it — never the caller.** A tempting shortcut
10
+ * is to have the client send an operation name and check that centrally. It cannot work: the
11
+ * client would then choose what gets checked, and omitting or renaming the operation would skip
12
+ * the gate. This map is server-side and keyed on the route.
13
+ *
14
+ * ── Mechanism here, DEFAULTS in each repo ────────────────────────────────────────────────────
15
+ * v1 and v2 mount different routes, so the tables are theirs. What is identical — merge order,
16
+ * the null-clears-a-default rule, the fail-open lookup, the DB loader — lives here, because two
17
+ * copies of merge logic is how the halves drift into disagreeing about who may do what.
18
+ *
19
+ * ── Precedence: DEFAULT < DB ─────────────────────────────────────────────────────────────────
20
+ * A `routefeatures` document may override or add an entry, so gating a new endpoint becomes a
21
+ * config edit rather than a deploy. `feature_code: null` DELETES a default — the escape hatch for
22
+ * "this should not be gated after all", and deliberately distinct from an absent row, which means
23
+ * "no opinion, use the default".
24
+ *
25
+ * DB rows carry a STRING only. Payload-aware routes — one endpoint serving several capabilities —
26
+ * need a function, which cannot be serialised, so those stay in each repo's defaults. That is a
27
+ * feature: the tricky ones stay in code, reviewed.
28
+ *
29
+ * ── Keying ───────────────────────────────────────────────────────────────────────────────────
30
+ * `ControllerName` + METHOD + the path exactly as written in that controller's `routes()` map —
31
+ * NOT the mounted URL, which is not known when routes are built and would silently stop matching
32
+ * if a prefix changed.
33
+ */
34
+
35
+ /**
36
+ * @typedef {string | ((req: any) => string | null)} FeatureRef
37
+ * A feature code, or a resolver returning one — `null` skips the gate for that request.
38
+ * @typedef {Record<string, Record<string, Record<string, FeatureRef>>>} RouteFeatureTable
39
+ * controller → method → path → FeatureRef
40
+ */
41
+
42
+ /**
43
+ * Merge DB rows over code defaults. Pure — exported so it can be tested without a database.
44
+ *
45
+ * @param {RouteFeatureTable} defaults
46
+ * @param {RouteFeatureTable} overrides
47
+ * @returns {RouteFeatureTable}
48
+ */
49
+ export function mergeRouteFeatures(defaults = {}, overrides = {}) {
50
+ const out = {};
51
+ for (const [ctrl, methods] of Object.entries(defaults)) {
52
+ out[ctrl] = {};
53
+ for (const [method, paths] of Object.entries(methods || {})) {
54
+ out[ctrl][method] = { ...paths };
55
+ }
56
+ }
57
+ for (const [ctrl, methods] of Object.entries(overrides || {})) {
58
+ for (const [method, paths] of Object.entries(methods || {})) {
59
+ for (const [path, code] of Object.entries(paths || {})) {
60
+ if (code === null) {
61
+ if (out[ctrl] && out[ctrl][method]) delete out[ctrl][method][path];
62
+ } else {
63
+ out[ctrl] = out[ctrl] || {};
64
+ out[ctrl][method] = out[ctrl][method] || {};
65
+ out[ctrl][method][path] = code;
66
+ }
67
+ }
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** Shape DB rows into the nested table. Tolerates malformed rows by skipping them. */
74
+ export function rowsToTable(rows = []) {
75
+ const out = {};
76
+ for (const r of rows) {
77
+ if (!r || !r.controller || !r.method || !r.path) continue;
78
+ const m = String(r.method).toLowerCase();
79
+ out[r.controller] = out[r.controller] || {};
80
+ out[r.controller][m] = out[r.controller][m] || {};
81
+ out[r.controller][m][r.path] = r.feature_code === undefined ? null : r.feature_code;
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * A map instance bound to one repo's defaults.
88
+ *
89
+ * Model-agnostic by design: the loader takes a `findRows` function rather than a Mongoose model,
90
+ * so this package stays free of any database dependency — the same rule the rest of it follows.
91
+ *
92
+ * @param {RouteFeatureTable} defaults
93
+ */
94
+ export function createRouteFeatureMap(defaults = {}) {
95
+ let dbOverrides = {};
96
+ // Recomputed only when the overrides change: this is consulted once per route at mount time,
97
+ // but merging on every lookup would still be needless work for a table that rarely changes.
98
+ let merged = mergeRouteFeatures(defaults, dbOverrides);
99
+
100
+ return {
101
+ /**
102
+ * The feature this route enforces, or null.
103
+ *
104
+ * Called while mounting routes, so it must NEVER throw: a lookup failure has to mean
105
+ * "ungated", exactly as before the map existed, rather than taking down boot.
106
+ */
107
+ featureForRoute(controllerName, method, path) {
108
+ try {
109
+ const byMethod = merged[controllerName];
110
+ if (!byMethod) return null;
111
+ const byPath = byMethod[String(method).toLowerCase()];
112
+ if (!byPath) return null;
113
+ const found = byPath[path];
114
+ return found === undefined ? null : found;
115
+ } catch {
116
+ return null;
117
+ }
118
+ },
119
+
120
+ /** Everything currently in force — for the drift check and the admin API. */
121
+ all() {
122
+ return merged;
123
+ },
124
+
125
+ /** The code half alone, so an admin API can show what is a default vs an override. */
126
+ defaults() {
127
+ return defaults;
128
+ },
129
+
130
+ /**
131
+ * Refresh the DB half. A failure keeps the last good map.
132
+ *
133
+ * NOTE: gates attach at MOUNT time, so a change here affects routes mounted afterwards —
134
+ * in practice, after a restart. Resolving per request would mean a database read in the hot
135
+ * path of every write; the restart is the cheaper trade, and callers are told so rather than
136
+ * left to discover it.
137
+ *
138
+ * @param {() => Promise<Array>} findRows returns the raw `routefeatures` documents
139
+ */
140
+ async load(findRows) {
141
+ try {
142
+ if (typeof findRows !== "function") return dbOverrides;
143
+ dbOverrides = rowsToTable(await findRows());
144
+ merged = mergeRouteFeatures(defaults, dbOverrides);
145
+ } catch {
146
+ // keep the last good map — a config read must never break boot or a request
147
+ }
148
+ return dbOverrides;
149
+ },
150
+ };
151
+ }
152
+
153
+ export default { createRouteFeatureMap, mergeRouteFeatures, rowsToTable };