@i4e/invest4edu-access-core 0.32.0 → 0.33.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 +128 -126
- package/package.json +57 -56
- package/src/access-config.js +68 -68
- package/src/access-resolver.js +269 -269
- package/src/access-schema.js +174 -174
- package/src/credits.js +128 -128
- package/src/entitlement-schema.js +194 -194
- package/src/entitlement-store.d.ts +99 -99
- package/src/entitlement-store.js +610 -610
- package/src/entitlement.js +300 -300
- package/src/grid-schema.js +231 -231
- package/src/index.js +75 -74
- package/src/proration.js +155 -155
- package/src/reportee-tree.js +129 -129
- package/src/role-capabilities.js +93 -93
- package/src/route-features.js +292 -292
- package/src/route-screen.js +45 -0
- package/src/subscription-lifecycle.js +106 -106
- package/src/tenant-context.js +26 -26
- package/src/tenant-plugin.js +177 -177
- package/src/visible-when.js +108 -108
package/src/route-features.js
CHANGED
|
@@ -1,292 +1,292 @@
|
|
|
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. Lookups happen per request, so merging on every
|
|
97
|
-
// one would be real waste for a table that changes about never.
|
|
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
|
-
* Consumers resolve gates PER REQUEST against this map, so a refresh takes effect without a
|
|
134
|
-
* restart. Resolving at mount time was the original design and was a bug: routes mount
|
|
135
|
-
* synchronously at boot, before the database is necessarily connected, so the map would only
|
|
136
|
-
* ever hold code defaults and every stored row was silently dead.
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Keep a map refreshed in the background.
|
|
157
|
-
*
|
|
158
|
-
* Every consumer needs this and it is identical everywhere: load once now, then on a timer,
|
|
159
|
-
* `unref()` so it never holds the process open, and swallow failures so a config read cannot
|
|
160
|
-
* break boot. Written three times in two repos before it moved here.
|
|
161
|
-
*
|
|
162
|
-
* @param {ReturnType<createRouteFeatureMap>} map
|
|
163
|
-
* @param {() => Promise<Array>} findRows may return null/throw before the DB is connected
|
|
164
|
-
* @param {number} [intervalMs=60000]
|
|
165
|
-
* @returns {() => void} stop
|
|
166
|
-
*/
|
|
167
|
-
export function startRouteFeatureRefresh(map, findRows, intervalMs = 60 * 1000) {
|
|
168
|
-
const tick = () => { map.load(findRows).catch(() => {}); };
|
|
169
|
-
const timer = setInterval(tick, intervalMs);
|
|
170
|
-
if (timer && typeof timer.unref === "function") timer.unref();
|
|
171
|
-
tick(); // harmless no-op until the database is up; the timer picks it up after
|
|
172
|
-
return () => clearInterval(timer);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Every route a controller registry mounts declaratively.
|
|
177
|
-
*
|
|
178
|
-
* Without this an UNGATED route is invisible to an admin screen — you would have to already know
|
|
179
|
-
* the controller name, method and exact path to gate one, which is barely better than editing the
|
|
180
|
-
* database by hand.
|
|
181
|
-
*
|
|
182
|
-
* Takes the registry's `name → class` mapping so the package stays framework-agnostic. Each class
|
|
183
|
-
* is instantiated to read `routes()`; safe because those are stateless declarations, and wrapped
|
|
184
|
-
* individually so one malformed controller cannot blank the whole list.
|
|
185
|
-
*
|
|
186
|
-
* @param {Record<string, any>} mappings
|
|
187
|
-
* @returns {Array<{controller: string, method: string, path: string}>}
|
|
188
|
-
*/
|
|
189
|
-
export function enumerateControllerRoutes(mappings = {}) {
|
|
190
|
-
const out = [];
|
|
191
|
-
for (const [name, Klass] of Object.entries(mappings)) {
|
|
192
|
-
try {
|
|
193
|
-
const inst = new Klass();
|
|
194
|
-
if (typeof inst.routes !== "function") continue;
|
|
195
|
-
for (const [method, paths] of Object.entries(inst.routes() || {})) {
|
|
196
|
-
for (const path of Object.keys(paths || {})) {
|
|
197
|
-
out.push({ controller: name, method: String(method).toLowerCase(), path });
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
} catch {
|
|
201
|
-
// one bad controller must not cost the whole list
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
return out;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* The rows an admin screen renders: every mapped route, then every mounted route with NO gate.
|
|
209
|
-
*
|
|
210
|
-
* Ungated sort FIRST — finding something unguarded is why anyone opens that screen, and a list of
|
|
211
|
-
* ninety mostly-gated routes buries them.
|
|
212
|
-
*
|
|
213
|
-
* @param {Object} p
|
|
214
|
-
* @param {Object} p.merged the in-force table (defaults + DB)
|
|
215
|
-
* @param {Set<string>} p.storedKeys `controller|method|path` present in the database
|
|
216
|
-
* @param {Set<string>} p.knownCodes defined action codes, to flag a gate that resolves to nothing
|
|
217
|
-
* @param {Array} p.discovered from enumerateControllerRoutes
|
|
218
|
-
*/
|
|
219
|
-
export function buildRouteFeatureRows({ merged = {}, storedKeys = new Set(), knownCodes = new Set(), discovered = [] } = {}) {
|
|
220
|
-
const rows = [];
|
|
221
|
-
const seen = new Set();
|
|
222
|
-
|
|
223
|
-
for (const [controller, methods] of Object.entries(merged)) {
|
|
224
|
-
for (const [method, paths] of Object.entries(methods || {})) {
|
|
225
|
-
for (const [path, code] of Object.entries(paths || {})) {
|
|
226
|
-
const key = `${controller}|${method}|${path}`;
|
|
227
|
-
seen.add(key);
|
|
228
|
-
const isFn = typeof code !== "string";
|
|
229
|
-
rows.push({
|
|
230
|
-
controller,
|
|
231
|
-
method,
|
|
232
|
-
path,
|
|
233
|
-
// A resolver cannot be shown as a value — it picks its code from the request.
|
|
234
|
-
feature_code: isFn ? null : code,
|
|
235
|
-
is_resolver: isFn,
|
|
236
|
-
source: storedKeys.has(key) ? "database" : "default",
|
|
237
|
-
// The check that matters: an undefined code makes the gate a silent no-op.
|
|
238
|
-
code_exists: isFn ? true : knownCodes.has(code),
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
for (const d of discovered) {
|
|
245
|
-
const key = `${d.controller}|${d.method}|${d.path}`;
|
|
246
|
-
if (seen.has(key)) continue;
|
|
247
|
-
rows.push({ ...d, feature_code: null, is_resolver: false, source: "unmapped", code_exists: true });
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
rows.sort((a, b) => {
|
|
251
|
-
const rank = (r) => (r.source === "unmapped" ? 0 : 1);
|
|
252
|
-
if (rank(a) !== rank(b)) return rank(a) - rank(b);
|
|
253
|
-
return `${a.controller}${a.path}`.localeCompare(`${b.controller}${b.path}`);
|
|
254
|
-
});
|
|
255
|
-
return rows;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Is this upsert allowed? `{ ok }` or `{ ok: false, message }`.
|
|
260
|
-
*
|
|
261
|
-
* The rule worth centralising: a route pointed at a feature_code the registry does not define
|
|
262
|
-
* enforces NOTHING — an unknown code resolves to rollout "off" — while looking mounted. That is
|
|
263
|
-
* the single most misleading state the access system can be in, and it has already been created
|
|
264
|
-
* by hand once. Refuse it at the boundary, in both backends, from one place.
|
|
265
|
-
*/
|
|
266
|
-
export function validateRouteFeatureUpsert({ controller, method, path, feature_code, knownCodes = new Set() } = {}) {
|
|
267
|
-
if (!controller || !method || !path) {
|
|
268
|
-
return { ok: false, message: "controller, method and path are required" };
|
|
269
|
-
}
|
|
270
|
-
if (feature_code !== null && typeof feature_code !== "string") {
|
|
271
|
-
return { ok: false, message: "feature_code must be a string, or null to clear" };
|
|
272
|
-
}
|
|
273
|
-
if (typeof feature_code === "string" && !knownCodes.has(feature_code)) {
|
|
274
|
-
return {
|
|
275
|
-
ok: false,
|
|
276
|
-
message:
|
|
277
|
-
`${feature_code} is not a defined action. A route pointed at an unknown code enforces `
|
|
278
|
-
+ `NOTHING — define the action first, then map it here.`,
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
return { ok: true };
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
export default {
|
|
285
|
-
createRouteFeatureMap,
|
|
286
|
-
mergeRouteFeatures,
|
|
287
|
-
rowsToTable,
|
|
288
|
-
startRouteFeatureRefresh,
|
|
289
|
-
enumerateControllerRoutes,
|
|
290
|
-
buildRouteFeatureRows,
|
|
291
|
-
validateRouteFeatureUpsert,
|
|
292
|
-
};
|
|
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. Lookups happen per request, so merging on every
|
|
97
|
+
// one would be real waste for a table that changes about never.
|
|
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
|
+
* Consumers resolve gates PER REQUEST against this map, so a refresh takes effect without a
|
|
134
|
+
* restart. Resolving at mount time was the original design and was a bug: routes mount
|
|
135
|
+
* synchronously at boot, before the database is necessarily connected, so the map would only
|
|
136
|
+
* ever hold code defaults and every stored row was silently dead.
|
|
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
|
+
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Keep a map refreshed in the background.
|
|
157
|
+
*
|
|
158
|
+
* Every consumer needs this and it is identical everywhere: load once now, then on a timer,
|
|
159
|
+
* `unref()` so it never holds the process open, and swallow failures so a config read cannot
|
|
160
|
+
* break boot. Written three times in two repos before it moved here.
|
|
161
|
+
*
|
|
162
|
+
* @param {ReturnType<createRouteFeatureMap>} map
|
|
163
|
+
* @param {() => Promise<Array>} findRows may return null/throw before the DB is connected
|
|
164
|
+
* @param {number} [intervalMs=60000]
|
|
165
|
+
* @returns {() => void} stop
|
|
166
|
+
*/
|
|
167
|
+
export function startRouteFeatureRefresh(map, findRows, intervalMs = 60 * 1000) {
|
|
168
|
+
const tick = () => { map.load(findRows).catch(() => {}); };
|
|
169
|
+
const timer = setInterval(tick, intervalMs);
|
|
170
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
171
|
+
tick(); // harmless no-op until the database is up; the timer picks it up after
|
|
172
|
+
return () => clearInterval(timer);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Every route a controller registry mounts declaratively.
|
|
177
|
+
*
|
|
178
|
+
* Without this an UNGATED route is invisible to an admin screen — you would have to already know
|
|
179
|
+
* the controller name, method and exact path to gate one, which is barely better than editing the
|
|
180
|
+
* database by hand.
|
|
181
|
+
*
|
|
182
|
+
* Takes the registry's `name → class` mapping so the package stays framework-agnostic. Each class
|
|
183
|
+
* is instantiated to read `routes()`; safe because those are stateless declarations, and wrapped
|
|
184
|
+
* individually so one malformed controller cannot blank the whole list.
|
|
185
|
+
*
|
|
186
|
+
* @param {Record<string, any>} mappings
|
|
187
|
+
* @returns {Array<{controller: string, method: string, path: string}>}
|
|
188
|
+
*/
|
|
189
|
+
export function enumerateControllerRoutes(mappings = {}) {
|
|
190
|
+
const out = [];
|
|
191
|
+
for (const [name, Klass] of Object.entries(mappings)) {
|
|
192
|
+
try {
|
|
193
|
+
const inst = new Klass();
|
|
194
|
+
if (typeof inst.routes !== "function") continue;
|
|
195
|
+
for (const [method, paths] of Object.entries(inst.routes() || {})) {
|
|
196
|
+
for (const path of Object.keys(paths || {})) {
|
|
197
|
+
out.push({ controller: name, method: String(method).toLowerCase(), path });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// one bad controller must not cost the whole list
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The rows an admin screen renders: every mapped route, then every mounted route with NO gate.
|
|
209
|
+
*
|
|
210
|
+
* Ungated sort FIRST — finding something unguarded is why anyone opens that screen, and a list of
|
|
211
|
+
* ninety mostly-gated routes buries them.
|
|
212
|
+
*
|
|
213
|
+
* @param {Object} p
|
|
214
|
+
* @param {Object} p.merged the in-force table (defaults + DB)
|
|
215
|
+
* @param {Set<string>} p.storedKeys `controller|method|path` present in the database
|
|
216
|
+
* @param {Set<string>} p.knownCodes defined action codes, to flag a gate that resolves to nothing
|
|
217
|
+
* @param {Array} p.discovered from enumerateControllerRoutes
|
|
218
|
+
*/
|
|
219
|
+
export function buildRouteFeatureRows({ merged = {}, storedKeys = new Set(), knownCodes = new Set(), discovered = [] } = {}) {
|
|
220
|
+
const rows = [];
|
|
221
|
+
const seen = new Set();
|
|
222
|
+
|
|
223
|
+
for (const [controller, methods] of Object.entries(merged)) {
|
|
224
|
+
for (const [method, paths] of Object.entries(methods || {})) {
|
|
225
|
+
for (const [path, code] of Object.entries(paths || {})) {
|
|
226
|
+
const key = `${controller}|${method}|${path}`;
|
|
227
|
+
seen.add(key);
|
|
228
|
+
const isFn = typeof code !== "string";
|
|
229
|
+
rows.push({
|
|
230
|
+
controller,
|
|
231
|
+
method,
|
|
232
|
+
path,
|
|
233
|
+
// A resolver cannot be shown as a value — it picks its code from the request.
|
|
234
|
+
feature_code: isFn ? null : code,
|
|
235
|
+
is_resolver: isFn,
|
|
236
|
+
source: storedKeys.has(key) ? "database" : "default",
|
|
237
|
+
// The check that matters: an undefined code makes the gate a silent no-op.
|
|
238
|
+
code_exists: isFn ? true : knownCodes.has(code),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for (const d of discovered) {
|
|
245
|
+
const key = `${d.controller}|${d.method}|${d.path}`;
|
|
246
|
+
if (seen.has(key)) continue;
|
|
247
|
+
rows.push({ ...d, feature_code: null, is_resolver: false, source: "unmapped", code_exists: true });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
rows.sort((a, b) => {
|
|
251
|
+
const rank = (r) => (r.source === "unmapped" ? 0 : 1);
|
|
252
|
+
if (rank(a) !== rank(b)) return rank(a) - rank(b);
|
|
253
|
+
return `${a.controller}${a.path}`.localeCompare(`${b.controller}${b.path}`);
|
|
254
|
+
});
|
|
255
|
+
return rows;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Is this upsert allowed? `{ ok }` or `{ ok: false, message }`.
|
|
260
|
+
*
|
|
261
|
+
* The rule worth centralising: a route pointed at a feature_code the registry does not define
|
|
262
|
+
* enforces NOTHING — an unknown code resolves to rollout "off" — while looking mounted. That is
|
|
263
|
+
* the single most misleading state the access system can be in, and it has already been created
|
|
264
|
+
* by hand once. Refuse it at the boundary, in both backends, from one place.
|
|
265
|
+
*/
|
|
266
|
+
export function validateRouteFeatureUpsert({ controller, method, path, feature_code, knownCodes = new Set() } = {}) {
|
|
267
|
+
if (!controller || !method || !path) {
|
|
268
|
+
return { ok: false, message: "controller, method and path are required" };
|
|
269
|
+
}
|
|
270
|
+
if (feature_code !== null && typeof feature_code !== "string") {
|
|
271
|
+
return { ok: false, message: "feature_code must be a string, or null to clear" };
|
|
272
|
+
}
|
|
273
|
+
if (typeof feature_code === "string" && !knownCodes.has(feature_code)) {
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
message:
|
|
277
|
+
`${feature_code} is not a defined action. A route pointed at an unknown code enforces `
|
|
278
|
+
+ `NOTHING — define the action first, then map it here.`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return { ok: true };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export default {
|
|
285
|
+
createRouteFeatureMap,
|
|
286
|
+
mergeRouteFeatures,
|
|
287
|
+
rowsToTable,
|
|
288
|
+
startRouteFeatureRefresh,
|
|
289
|
+
enumerateControllerRoutes,
|
|
290
|
+
buildRouteFeatureRows,
|
|
291
|
+
validateRouteFeatureUpsert,
|
|
292
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route → screen: the join both backends and the action seeder make.
|
|
3
|
+
*
|
|
4
|
+
* A screen's `feature_code` is a property of its `access_features` row, and the row is found by
|
|
5
|
+
* the screen's route. Anything composing `MODULE.SCREEN.ACTION` for a screen must therefore start
|
|
6
|
+
* from the route and READ the prefix, never spell it. The activity log learned this the hard way:
|
|
7
|
+
* a prefix guessed as `DISTRIBUTOR_LEADS.DISTRIBUTOR_LEAD` matched no row, and because an unknown
|
|
8
|
+
* code resolves to rollout "off", every gate composed from it silently never fired.
|
|
9
|
+
*
|
|
10
|
+
* Pure, like the rest of this package: the caller hands in the feature rows it already holds
|
|
11
|
+
* (each backend's in-memory registry, or a raw collection scan in a script) and gets the matching
|
|
12
|
+
* screen back. One normaliser serves seeding and runtime, so the two cannot disagree on what "the
|
|
13
|
+
* same route" means.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Canonical form of a route for comparison: no leading or trailing slashes, lowercase.
|
|
18
|
+
* `/home/customer/leads/` and `home/Customer/Leads` name the same screen.
|
|
19
|
+
*/
|
|
20
|
+
export function normalizeRoute(route) {
|
|
21
|
+
return String(route || "")
|
|
22
|
+
.replace(/^\/+|\/+$/g, "")
|
|
23
|
+
.toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The `feature_type: 'screen'` row whose route equals `route`, or null.
|
|
28
|
+
*
|
|
29
|
+
* Null, never a fabricated code: a route with no screen row means the screen is not registered in
|
|
30
|
+
* this environment, and the caller must treat that as "no gate can be composed" rather than compose
|
|
31
|
+
* one no grant will ever match. An empty `features` list (a registry not yet loaded) answers null
|
|
32
|
+
* too — a caller that must tell "not loaded" from "not registered" checks the registry itself.
|
|
33
|
+
*
|
|
34
|
+
* @param {Array<{feature_type?: string, route?: string|null, feature_code: string}>} features
|
|
35
|
+
* @param {string} route
|
|
36
|
+
*/
|
|
37
|
+
export function screenForRoute(features, route) {
|
|
38
|
+
const wanted = normalizeRoute(route);
|
|
39
|
+
if (!wanted || !Array.isArray(features)) return null;
|
|
40
|
+
return (
|
|
41
|
+
features.find(
|
|
42
|
+
(f) => f && f.feature_type === "screen" && f.route && normalizeRoute(f.route) === wanted,
|
|
43
|
+
) || null
|
|
44
|
+
);
|
|
45
|
+
}
|