@i4e/invest4edu-access-core 0.24.0 → 0.26.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 +1 -1
- package/src/entitlement-store.js +41 -1
- package/src/entitlement.js +29 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i4e/invest4edu-access-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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": {
|
package/src/entitlement-store.js
CHANGED
|
@@ -53,9 +53,34 @@ const noopLogger = { warn() {}, info() {} };
|
|
|
53
53
|
* would silently look unsubscribed.
|
|
54
54
|
* @param {Object} [opts.logger] anything with `warn`
|
|
55
55
|
*/
|
|
56
|
-
export function createEntitlementStore({
|
|
56
|
+
export function createEntitlementStore({
|
|
57
|
+
getDb,
|
|
58
|
+
toObjectId = (v) => v,
|
|
59
|
+
logger = noopLogger,
|
|
60
|
+
/**
|
|
61
|
+
* THE subscription master switch.
|
|
62
|
+
*
|
|
63
|
+
* One place that makes the whole domain inert: no entitlement decides anything, no counter
|
|
64
|
+
* moves, no subject is auto-provisioned. Per-feature `rollout_mode` still governs which gates
|
|
65
|
+
* are live once this is on — this is the switch above all of them, for shipping the code long
|
|
66
|
+
* before the product.
|
|
67
|
+
*
|
|
68
|
+
* OFF by default, deliberately. A release must be able to carry every line of this and change
|
|
69
|
+
* nothing for a single user; a default of "on" would make that depend on remembering to set a
|
|
70
|
+
* flag, and the failure mode is customers being charged and refused.
|
|
71
|
+
*
|
|
72
|
+
* Resolved by the CALLER — env, a config document, whatever each service already uses — so this
|
|
73
|
+
* package keeps no opinion about where configuration lives.
|
|
74
|
+
*/
|
|
75
|
+
isEnabled = () => String(process.env.SUBSCRIPTION_ENABLED || "").toLowerCase() === "true",
|
|
76
|
+
} = {}) {
|
|
57
77
|
if (typeof getDb !== "function") throw new Error("createEntitlementStore requires getDb()");
|
|
58
78
|
|
|
79
|
+
/** Resolved per call, never cached, so flipping the switch takes effect without a restart. */
|
|
80
|
+
const enabled = async () => {
|
|
81
|
+
try { return (await isEnabled()) === true; } catch { return false; }
|
|
82
|
+
};
|
|
83
|
+
|
|
59
84
|
const oid = (v) => {
|
|
60
85
|
try { return toObjectId(String(v)); } catch { return v; }
|
|
61
86
|
};
|
|
@@ -82,6 +107,9 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
82
107
|
bypass = false, now = new Date(),
|
|
83
108
|
} = {}) {
|
|
84
109
|
try {
|
|
110
|
+
// Master switch off ⇒ the same OPEN answer as "no subscription": allowed, unshaped,
|
|
111
|
+
// unmetered. Every caller already handles this, because it is the fail-open path.
|
|
112
|
+
if (!(await enabled())) return OPEN("subscription_disabled");
|
|
85
113
|
const db = getDb();
|
|
86
114
|
if (!db || !subjectType || !subjectId) return OPEN("no_subject");
|
|
87
115
|
|
|
@@ -324,6 +352,14 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
324
352
|
const useRow = row || entitlement?.carriedRow;
|
|
325
353
|
const ident = identity || entitlement?.carriedIdentity || {};
|
|
326
354
|
// An unmetered feature has nothing to count; saying so is not a failure.
|
|
355
|
+
/**
|
|
356
|
+
* Checked before anything else, including the not-metered short-circuits: "the domain is
|
|
357
|
+
* off" is a truer answer than "this feature is not metered", and it has to hold even when
|
|
358
|
+
* the caller passed nothing useful. Returning `consumed: false` rather than throwing keeps
|
|
359
|
+
* callers on the path they already have for "the counter did not move".
|
|
360
|
+
*/
|
|
361
|
+
if (!(await enabled())) return { consumed: false, reason: "subscription_disabled" };
|
|
362
|
+
|
|
327
363
|
if (entitlement && !entitlement.metered) return { consumed: false, reason: "not_metered" };
|
|
328
364
|
if (!db || !subId || !useRow) return { consumed: false, reason: "not_metered" };
|
|
329
365
|
|
|
@@ -466,6 +502,10 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
466
502
|
*/
|
|
467
503
|
async function provisionDefault({ subjectType, subjectId, accountId = null, now = new Date() } = {}) {
|
|
468
504
|
try {
|
|
505
|
+
// No auto-provisioning while the domain is off, whatever the catalogue says. This is the
|
|
506
|
+
// one that would otherwise write rows into production on the first distributor created.
|
|
507
|
+
if (!(await enabled())) return null;
|
|
508
|
+
|
|
469
509
|
const db = getDb();
|
|
470
510
|
if (!db || !subjectType || !subjectId) return null;
|
|
471
511
|
|
package/src/entitlement.js
CHANGED
|
@@ -144,6 +144,12 @@ export function resolveEntitlement({
|
|
|
144
144
|
const base = {
|
|
145
145
|
quota: null, used, remaining: null, period: null, subject_key: null,
|
|
146
146
|
overage_policy: "unlimited", metered: false,
|
|
147
|
+
/**
|
|
148
|
+
* A CEILING that shapes a response — top-N picks, tips per answer — as opposed to `quota`,
|
|
149
|
+
* which is a budget that decrements. Null when the plan sets none. Present on every result so
|
|
150
|
+
* a caller never has to tell "no ceiling" from "this resolver is too old to send one".
|
|
151
|
+
*/
|
|
152
|
+
limit: null,
|
|
147
153
|
};
|
|
148
154
|
const open = (reason) => ({ ...base, unlocked: true, allowed: true, reason });
|
|
149
155
|
|
|
@@ -211,8 +217,28 @@ export function resolveEntitlement({
|
|
|
211
217
|
}
|
|
212
218
|
|
|
213
219
|
// Unlocked but not metered → nothing to count.
|
|
220
|
+
/**
|
|
221
|
+
* Unlocked but not counted.
|
|
222
|
+
*
|
|
223
|
+
* `limit` is carried through even though nothing is metered, because a number on an unmetered
|
|
224
|
+
* row is a CEILING rather than a budget: "your top-picks list shows 5" is read on every request
|
|
225
|
+
* and never decrements. Returning it lets a caller shape its response from the plan instead of
|
|
226
|
+
* hard-coding the shape, which is the difference between a config edit and a deploy.
|
|
227
|
+
*
|
|
228
|
+
* `quota` deliberately stays null here — it means "how much is left", and nothing is being
|
|
229
|
+
* spent. Reusing it for a ceiling would make `remaining` a lie and invite a consume() call that
|
|
230
|
+
* should never happen.
|
|
231
|
+
*/
|
|
214
232
|
if (!feature.is_meterable || row.quota === null || row.quota === undefined) {
|
|
215
|
-
|
|
233
|
+
// null/undefined is UNSET, and Number() turns both into 0 — which would read as "show
|
|
234
|
+
// nothing", the exact opposite. Only a real number is a ceiling.
|
|
235
|
+
const raw = row.quota;
|
|
236
|
+
const ceiling = raw === null || raw === undefined || raw === "" ? null : Number(raw);
|
|
237
|
+
return {
|
|
238
|
+
...open(feature.is_meterable ? R.OK : R.NOT_METERED),
|
|
239
|
+
overage_policy: row.overage_policy || "unlimited",
|
|
240
|
+
limit: ceiling !== null && Number.isFinite(ceiling) && ceiling >= 0 ? ceiling : null,
|
|
241
|
+
};
|
|
216
242
|
}
|
|
217
243
|
|
|
218
244
|
const granularity = row.period_granularity || "month";
|
|
@@ -252,6 +278,8 @@ export function resolveEntitlement({
|
|
|
252
278
|
|
|
253
279
|
return {
|
|
254
280
|
unlocked: true,
|
|
281
|
+
// Declared even on a metered result: absence must never be mistaken for "no ceiling".
|
|
282
|
+
limit: null,
|
|
255
283
|
quota: Number(row.quota),
|
|
256
284
|
used: Number(used || 0),
|
|
257
285
|
remaining,
|