@i4e/invest4edu-access-core 0.10.0 → 0.12.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 +3 -2
- package/src/route-features.js +146 -7
- package/src/subscription-lifecycle.js +69 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i4e/invest4edu-access-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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": {
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"./entitlement": "./src/entitlement.js",
|
|
17
17
|
"./entitlement-schema": "./src/entitlement-schema.js",
|
|
18
18
|
"./grid-schema": "./src/grid-schema.js",
|
|
19
|
-
"./route-features": "./src/route-features.js"
|
|
19
|
+
"./route-features": "./src/route-features.js",
|
|
20
|
+
"./subscription-lifecycle": "./src/subscription-lifecycle.js"
|
|
20
21
|
},
|
|
21
22
|
"scripts": {
|
|
22
23
|
"test": "node --test test/"
|
package/src/route-features.js
CHANGED
|
@@ -93,8 +93,8 @@ export function rowsToTable(rows = []) {
|
|
|
93
93
|
*/
|
|
94
94
|
export function createRouteFeatureMap(defaults = {}) {
|
|
95
95
|
let dbOverrides = {};
|
|
96
|
-
// Recomputed only when the overrides change
|
|
97
|
-
//
|
|
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
98
|
let merged = mergeRouteFeatures(defaults, dbOverrides);
|
|
99
99
|
|
|
100
100
|
return {
|
|
@@ -130,10 +130,10 @@ export function createRouteFeatureMap(defaults = {}) {
|
|
|
130
130
|
/**
|
|
131
131
|
* Refresh the DB half. A failure keeps the last good map.
|
|
132
132
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
137
|
*
|
|
138
138
|
* @param {() => Promise<Array>} findRows returns the raw `routefeatures` documents
|
|
139
139
|
*/
|
|
@@ -150,4 +150,143 @@ export function createRouteFeatureMap(defaults = {}) {
|
|
|
150
150
|
};
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
|
|
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,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription lifecycle — statuses, transitions and event types. SHARED, because code branches
|
|
3
|
+
* on these in both backends and a status list that drifts between them means one side treats a
|
|
4
|
+
* subscription as live while the other has cut access (invariant #11).
|
|
5
|
+
*
|
|
6
|
+
* The machine:
|
|
7
|
+
*
|
|
8
|
+
* trialing → active | cancelled | expired
|
|
9
|
+
* active → past_due | blocked | cancelled
|
|
10
|
+
* past_due → active | blocked | cancelled (payment recovered | gave up | user quit)
|
|
11
|
+
* blocked → active | cancelled (recovered | closed)
|
|
12
|
+
* cancelled / expired → (terminal — a new purchase creates a NEW subscription)
|
|
13
|
+
*
|
|
14
|
+
* Terminal states stay terminal on purpose: "reactivating" a cancelled row would resurrect its
|
|
15
|
+
* history, overrides and period as if nothing happened. A fresh subscription is honest about the
|
|
16
|
+
* gap and keeps the audit trail of the old one intact.
|
|
17
|
+
*
|
|
18
|
+
* Upgrade/downgrade are TRANSITIONS OF PLAN, not of status — a plan change happens on a live
|
|
19
|
+
* subscription and does not appear here.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const SUBSCRIPTION_STATUSES = Object.freeze([
|
|
23
|
+
"trialing", "active", "past_due", "blocked", "cancelled", "expired",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** States in which entitlements resolve. past_due keeps working — cutting access the moment a
|
|
27
|
+
* payment is late loses more than it protects; `blocked` is the deliberate cut-off. */
|
|
28
|
+
export const LIVE_STATUSES = Object.freeze(["trialing", "active", "past_due"]);
|
|
29
|
+
|
|
30
|
+
export const TRANSITIONS = Object.freeze({
|
|
31
|
+
trialing: ["active", "cancelled", "expired"],
|
|
32
|
+
active: ["past_due", "blocked", "cancelled"],
|
|
33
|
+
past_due: ["active", "blocked", "cancelled"],
|
|
34
|
+
blocked: ["active", "cancelled"],
|
|
35
|
+
cancelled: [],
|
|
36
|
+
expired: [],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** `{ ok }` or `{ ok: false, message }` naming the legal moves — the caller shows it verbatim. */
|
|
40
|
+
export function canTransition(from, to) {
|
|
41
|
+
const allowed = TRANSITIONS[from];
|
|
42
|
+
if (!allowed) return { ok: false, message: `${from} is not a subscription status` };
|
|
43
|
+
if (from === to) return { ok: false, message: `already ${from}` };
|
|
44
|
+
if (!allowed.includes(to)) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
message: allowed.length
|
|
48
|
+
? `${from} can only move to: ${allowed.join(", ")}`
|
|
49
|
+
: `${from} is terminal — a new purchase creates a new subscription`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return { ok: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Event types — the audit spine AND what the Events Engine receives (`subscription.<type>`).
|
|
57
|
+
* Engagement and monitoring both hang off this list, so an unlisted type is an event nobody can
|
|
58
|
+
* subscribe to: recording one is refused rather than silently accepted.
|
|
59
|
+
*/
|
|
60
|
+
export const SUBSCRIPTION_EVENT_TYPES = Object.freeze([
|
|
61
|
+
"created", "trial_started", "activated", "renewed",
|
|
62
|
+
"plan_changed", "cycle_changed", "period_extended",
|
|
63
|
+
"override_set", "override_removed",
|
|
64
|
+
"status_changed", "cancelled", "blocked", "expired",
|
|
65
|
+
"payment_captured", "payment_failed",
|
|
66
|
+
"quota_exceeded",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
export default { SUBSCRIPTION_STATUSES, LIVE_STATUSES, TRANSITIONS, canTransition, SUBSCRIPTION_EVENT_TYPES };
|