@i4e/invest4edu-access-core 0.12.0 → 0.14.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/entitlement-store.js +303 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i4e/invest4edu-access-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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": {
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"./entitlement-schema": "./src/entitlement-schema.js",
|
|
18
18
|
"./grid-schema": "./src/grid-schema.js",
|
|
19
19
|
"./route-features": "./src/route-features.js",
|
|
20
|
-
"./subscription-lifecycle": "./src/subscription-lifecycle.js"
|
|
20
|
+
"./subscription-lifecycle": "./src/subscription-lifecycle.js",
|
|
21
|
+
"./entitlement-store": "./src/entitlement-store.js"
|
|
21
22
|
},
|
|
22
23
|
"scripts": {
|
|
23
24
|
"test": "node --test test/"
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The entitlement STORE — reading a subject's entitlement, and consuming a unit of it.
|
|
3
|
+
*
|
|
4
|
+
* `entitlement.js` next door holds the pure DECISION (given a subscription, rows and a usage
|
|
5
|
+
* count, what is allowed). This holds the part that talks to the database, because every backend
|
|
6
|
+
* that gates a metered feature needs exactly the same two operations and there is nothing
|
|
7
|
+
* app-specific about either.
|
|
8
|
+
*
|
|
9
|
+
* ── Why this exists ──────────────────────────────────────────────────────────────────────────
|
|
10
|
+
* v1 and v2 each grew their own copy of these two functions, and they had ALREADY diverged in
|
|
11
|
+
* shape before a third service needed them: v1's `entitlementFor` derives the subject from `req`
|
|
12
|
+
* and returns carried state for `consume` to reuse, while v2's takes explicit ids and splits
|
|
13
|
+
* `findSubscription` out. Two implementations of one contract is a bug waiting for the day they
|
|
14
|
+
* disagree about whether someone is over quota. A third would have made it certain.
|
|
15
|
+
*
|
|
16
|
+
* So the mechanism lives here and only the DATA differs — which database, which logger. Nothing
|
|
17
|
+
* Mongoose-shaped crosses this boundary: callers inject a `getDb()` returning a raw driver Db, so
|
|
18
|
+
* the package stays model-agnostic and services keep owning their own connections.
|
|
19
|
+
*
|
|
20
|
+
* ── Fail open, always ────────────────────────────────────────────────────────────────────────
|
|
21
|
+
* Entitlement never blocks on infrastructure error. RBAC is the layer that fails closed; this one
|
|
22
|
+
* is commercial, and refusing paid work because our own counter was unreachable turns a small
|
|
23
|
+
* outage into a customer-visible one. Absence is far more likely to be our gap than a customer
|
|
24
|
+
* genuinely owning nothing.
|
|
25
|
+
*/
|
|
26
|
+
import { resolveEntitlement, periodKey, subjectKeyFor } from "./entitlement.js";
|
|
27
|
+
import { LIVE_STATUSES } from "./subscription-lifecycle.js";
|
|
28
|
+
|
|
29
|
+
/** Every failure path returns this, so callers never have to branch on "did the lookup work". */
|
|
30
|
+
const OPEN = (reason) => ({
|
|
31
|
+
allowed: true,
|
|
32
|
+
unlocked: true,
|
|
33
|
+
metered: false,
|
|
34
|
+
quota: null,
|
|
35
|
+
used: 0,
|
|
36
|
+
remaining: null,
|
|
37
|
+
overage_policy: "unlimited",
|
|
38
|
+
reason,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const noopLogger = { warn() {}, info() {} };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build a store bound to one service's database.
|
|
45
|
+
*
|
|
46
|
+
* @param {Object} opts
|
|
47
|
+
* @param {Function} opts.getDb returns a raw MongoDB `Db`, or null/undefined when unavailable
|
|
48
|
+
* @param {Function} [opts.toObjectId] cast a string id the way this service's driver expects.
|
|
49
|
+
* Defaults to passing the value through — services using
|
|
50
|
+
* Mongoose should pass `mongoose.Types.ObjectId`, because a
|
|
51
|
+
* string will not match an ObjectId field and the customer
|
|
52
|
+
* would silently look unsubscribed.
|
|
53
|
+
* @param {Object} [opts.logger] anything with `warn`
|
|
54
|
+
*/
|
|
55
|
+
export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger = noopLogger } = {}) {
|
|
56
|
+
if (typeof getDb !== "function") throw new Error("createEntitlementStore requires getDb()");
|
|
57
|
+
|
|
58
|
+
const oid = (v) => {
|
|
59
|
+
try { return toObjectId(String(v)); } catch { return v; }
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The live subscription governing this subject, or null. */
|
|
63
|
+
async function findSubscription({ subjectType, subjectId } = {}) {
|
|
64
|
+
const db = getDb();
|
|
65
|
+
if (!db || !subjectType || !subjectId) return null;
|
|
66
|
+
return db.collection("account_subscriptions").findOne({
|
|
67
|
+
subject_type: subjectType,
|
|
68
|
+
subject_id: oid(subjectId),
|
|
69
|
+
status: { $in: LIVE_STATUSES },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve entitlement for one feature.
|
|
75
|
+
*
|
|
76
|
+
* Returns an OPEN result whenever anything is missing — no subscription, no plan row, no
|
|
77
|
+
* database — for the reason in the header.
|
|
78
|
+
*/
|
|
79
|
+
async function entitlementFor({
|
|
80
|
+
subjectType, subjectId, featureCode, feature = {}, identity = {},
|
|
81
|
+
bypass = false, now = new Date(),
|
|
82
|
+
} = {}) {
|
|
83
|
+
try {
|
|
84
|
+
const db = getDb();
|
|
85
|
+
if (!db || !subjectType || !subjectId) return OPEN("no_subject");
|
|
86
|
+
|
|
87
|
+
const subscription = await findSubscription({ subjectType, subjectId });
|
|
88
|
+
if (!subscription) return resolveEntitlement({ subscription: null, featureCode, feature, bypass, now });
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Trial terms: while `trialing`, rows with cycle "trial" take precedence over the purchased
|
|
92
|
+
* cycle's — that is how a trial offers LESS than the paid plan without being a separate
|
|
93
|
+
* plan. No trial rows ⇒ the paid terms apply during trial, which is the
|
|
94
|
+
* time-limited-full-plan case.
|
|
95
|
+
*/
|
|
96
|
+
const cycles = subscription.status === "trialing"
|
|
97
|
+
? ["trial", subscription.cycle]
|
|
98
|
+
: [subscription.cycle];
|
|
99
|
+
const fetched = await db.collection("plan_entitlements").find({
|
|
100
|
+
plan_code: subscription.plan_code,
|
|
101
|
+
cycle: { $in: cycles },
|
|
102
|
+
feature_code: featureCode,
|
|
103
|
+
}).toArray();
|
|
104
|
+
const trialRows = fetched.filter((r) => r.cycle === "trial");
|
|
105
|
+
const rows = subscription.status === "trialing" && trialRows.length
|
|
106
|
+
? trialRows
|
|
107
|
+
: fetched.filter((r) => r.cycle === subscription.cycle);
|
|
108
|
+
|
|
109
|
+
// Only read the counter when something could actually be metered — an unmetered feature
|
|
110
|
+
// should not cost a query on every request.
|
|
111
|
+
let used = 0;
|
|
112
|
+
const row = rows[0] || null;
|
|
113
|
+
if (feature.is_meterable && row) {
|
|
114
|
+
const period = periodKey(row.period_granularity || "month", { now, subscriptionId: subscription._id });
|
|
115
|
+
const subjectKey = subjectKeyFor(row.quota_scope, identity) || "";
|
|
116
|
+
const usage = await db.collection("subscription_usage").findOne({
|
|
117
|
+
subscription_id: subscription._id,
|
|
118
|
+
feature_code: featureCode,
|
|
119
|
+
period,
|
|
120
|
+
subject_key: subjectKey,
|
|
121
|
+
});
|
|
122
|
+
used = usage?.used || 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const result = await resolveEntitlement({
|
|
126
|
+
subscription, entitlementRows: rows, featureCode, feature, used, identity, bypass, now,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Carry what consume() needs, so it cannot recompute the period key and count against a
|
|
130
|
+
// different bucket than the one that was just checked.
|
|
131
|
+
return {
|
|
132
|
+
...result,
|
|
133
|
+
carriedSubscriptionId: subscription._id,
|
|
134
|
+
carriedIdentity: identity,
|
|
135
|
+
carriedRow: row,
|
|
136
|
+
};
|
|
137
|
+
} catch (e) {
|
|
138
|
+
logger.warn(`[entitlement] resolve failed for ${featureCode}, allowing through: ${e.message}`);
|
|
139
|
+
return OPEN("resolve_error");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Consume one unit — race-free by construction.
|
|
145
|
+
*
|
|
146
|
+
* The `used < quota` test lives inside the FILTER, so Mongo decides atomically whether the
|
|
147
|
+
* increment may happen; matchedCount 0 means the ceiling was already reached. A read-then-write
|
|
148
|
+
* would let two concurrent requests both pass a 5-of-5 check and land a 6th.
|
|
149
|
+
*
|
|
150
|
+
* Only `block` enforces the ceiling — under `warn` the count still rises, and that record is the
|
|
151
|
+
* evidence for whether a quota is right before anyone is ever refused.
|
|
152
|
+
*
|
|
153
|
+
* Accepts either the result of `entitlementFor` (preferred — it carries the exact bucket that
|
|
154
|
+
* was checked) or the explicit pieces, so both existing call shapes keep working.
|
|
155
|
+
*/
|
|
156
|
+
async function consume({
|
|
157
|
+
entitlement, featureCode, n = 1, now = new Date(),
|
|
158
|
+
subscriptionId, row, identity,
|
|
159
|
+
} = {}) {
|
|
160
|
+
try {
|
|
161
|
+
const db = getDb();
|
|
162
|
+
const subId = subscriptionId || entitlement?.carriedSubscriptionId;
|
|
163
|
+
const useRow = row || entitlement?.carriedRow;
|
|
164
|
+
const ident = identity || entitlement?.carriedIdentity || {};
|
|
165
|
+
// An unmetered feature has nothing to count; saying so is not a failure.
|
|
166
|
+
if (entitlement && !entitlement.metered) return { consumed: false, reason: "not_metered" };
|
|
167
|
+
if (!db || !subId || !useRow) return { consumed: false, reason: "not_metered" };
|
|
168
|
+
|
|
169
|
+
const period = periodKey(useRow.period_granularity || "month", { now, subscriptionId: subId });
|
|
170
|
+
const subjectKey = subjectKeyFor(useRow.quota_scope, ident) || "";
|
|
171
|
+
const filter = {
|
|
172
|
+
subscription_id: subId,
|
|
173
|
+
feature_code: featureCode,
|
|
174
|
+
period,
|
|
175
|
+
subject_key: subjectKey,
|
|
176
|
+
};
|
|
177
|
+
const policy = useRow.overage_policy || "block";
|
|
178
|
+
const capped = policy === "block" && useRow.quota != null;
|
|
179
|
+
|
|
180
|
+
// Unguarded (warn / unlimited): a plain upsert is right — the count rises either way, and
|
|
181
|
+
// that record is the evidence for whether a quota is set correctly before anyone is refused.
|
|
182
|
+
if (!capped) {
|
|
183
|
+
await db.collection("subscription_usage").updateOne(
|
|
184
|
+
filter, { $inc: { used: n }, $setOnInsert: { ...filter } }, { upsert: true },
|
|
185
|
+
);
|
|
186
|
+
return { consumed: true };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Capped: try the GUARDED update first, without upsert.
|
|
190
|
+
const guarded = { ...filter, used: { $lt: Number(useRow.quota) } };
|
|
191
|
+
const hit = await db.collection("subscription_usage").updateOne(guarded, { $inc: { used: n } });
|
|
192
|
+
if (hit.matchedCount > 0) return { consumed: true };
|
|
193
|
+
|
|
194
|
+
// No match means either "no row yet" (first use this period) or "quota reached". Distinguish
|
|
195
|
+
// by INSERTING — the unique index makes that safe under concurrency: exactly one racer wins
|
|
196
|
+
// and the loser's duplicate-key means the row now exists, i.e. the quota case.
|
|
197
|
+
//
|
|
198
|
+
// Doing this as an upsert on the guarded filter instead would rely on a duplicate-key
|
|
199
|
+
// EXCEPTION to mean "quota exceeded" — correct by accident, and unreadable.
|
|
200
|
+
try {
|
|
201
|
+
await db.collection("subscription_usage").insertOne({ ...filter, used: n, createdAt: new Date() });
|
|
202
|
+
return { consumed: true };
|
|
203
|
+
} catch (e) {
|
|
204
|
+
if (e?.code === 11000) return { consumed: false, reason: "quota_exceeded" };
|
|
205
|
+
throw e;
|
|
206
|
+
}
|
|
207
|
+
} catch (e) {
|
|
208
|
+
logger.warn(`[entitlement] consume failed for ${featureCode}: ${e.message}`);
|
|
209
|
+
// Fail open on infrastructure error: never refuse a request because our counter misbehaved.
|
|
210
|
+
return { consumed: false, reason: "error" };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Give a subject the default plan, if there is one and they have none.
|
|
216
|
+
*
|
|
217
|
+
* A distributor who has never bought anything currently resolves to "no subscription", which
|
|
218
|
+
* fails OPEN — so today every distributor silently has unlimited everything. A default plan is
|
|
219
|
+
* how that becomes deliberate: a real package with real limits, assigned automatically, so the
|
|
220
|
+
* system's answer to "what may this person do" stops being "we never decided".
|
|
221
|
+
*
|
|
222
|
+
* ── Idempotent, and safe to call from anywhere ───────────────────────────────────────────────
|
|
223
|
+
* Returns the existing subscription untouched if one is live. That matters because the natural
|
|
224
|
+
* call site is distributor creation, and distributors get created down several paths (self
|
|
225
|
+
* onboarding, admin, draft approval, bulk import) — a provisioner that could double-assign would
|
|
226
|
+
* have to be wired carefully into exactly one of them, which is how coverage gaps happen. This
|
|
227
|
+
* one can be called from all of them, or twice, or after the fact.
|
|
228
|
+
*
|
|
229
|
+
* ── Never fatal ──────────────────────────────────────────────────────────────────────────────
|
|
230
|
+
* Returns null on any failure rather than throwing. Callers create distributors; a subscription
|
|
231
|
+
* that could not be provisioned must never be the reason a distributor does not exist. The gap
|
|
232
|
+
* is recoverable by calling this again — a failed creation is not.
|
|
233
|
+
*
|
|
234
|
+
* @param {Object} p
|
|
235
|
+
* @param {string} p.subjectType "distributor" | "client"
|
|
236
|
+
* @param {string} p.subjectId
|
|
237
|
+
* @param {string} [p.accountId]
|
|
238
|
+
* @param {Date} [p.now]
|
|
239
|
+
* @returns {Promise<Object|null>} the subscription (existing or new), or null
|
|
240
|
+
*/
|
|
241
|
+
async function provisionDefault({ subjectType, subjectId, accountId = null, now = new Date() } = {}) {
|
|
242
|
+
try {
|
|
243
|
+
const db = getDb();
|
|
244
|
+
if (!db || !subjectType || !subjectId) return null;
|
|
245
|
+
|
|
246
|
+
const existing = await findSubscription({ subjectType, subjectId });
|
|
247
|
+
if (existing) return existing;
|
|
248
|
+
|
|
249
|
+
// The default plan is a property of the CATALOGUE, not of this code — so which package new
|
|
250
|
+
// distributors get, and for how long, is changed by editing a plan rather than deploying.
|
|
251
|
+
const plan = await db.collection("plan_catalog").findOne({
|
|
252
|
+
default_for: subjectType,
|
|
253
|
+
status: "active",
|
|
254
|
+
});
|
|
255
|
+
if (!plan) return null;
|
|
256
|
+
|
|
257
|
+
const days = Number(plan.default_duration_days) || 0;
|
|
258
|
+
const start = new Date(now);
|
|
259
|
+
const end = new Date(now);
|
|
260
|
+
if (days > 0) end.setDate(end.getDate() + days);
|
|
261
|
+
// No duration means an open-ended default tier; period_end null reads as "does not lapse".
|
|
262
|
+
const periodEnd = days > 0 ? end : null;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Status is `active`, not `trialing`.
|
|
266
|
+
*
|
|
267
|
+
* This is a real package on real terms, not a taste of a bigger one — and `trialing` would
|
|
268
|
+
* make trial-cycle entitlement rows take precedence, quietly applying terms nobody wrote for
|
|
269
|
+
* this plan. The trial mechanism stays available for plans that actually want it.
|
|
270
|
+
*/
|
|
271
|
+
const doc = {
|
|
272
|
+
account_id: accountId ? oid(accountId) : null,
|
|
273
|
+
subject_type: subjectType,
|
|
274
|
+
subject_id: oid(subjectId),
|
|
275
|
+
plan_code: plan.plan_code,
|
|
276
|
+
cycle: plan.default_cycle || "monthly",
|
|
277
|
+
status: "active",
|
|
278
|
+
period_start: start,
|
|
279
|
+
period_end: periodEnd,
|
|
280
|
+
provisioned_default: true, // so a report can tell "given" from "bought"
|
|
281
|
+
createdAt: new Date(),
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
await db.collection("account_subscriptions").insertOne(doc);
|
|
286
|
+
} catch (e) {
|
|
287
|
+
// Two creation paths racing on the same subject. The partial unique index on a live
|
|
288
|
+
// subscription is what makes this safe: one wins, and the loser reads the winner's row
|
|
289
|
+
// rather than creating a duplicate.
|
|
290
|
+
if (e?.code === 11000) return findSubscription({ subjectType, subjectId });
|
|
291
|
+
throw e;
|
|
292
|
+
}
|
|
293
|
+
return doc;
|
|
294
|
+
} catch (e) {
|
|
295
|
+
logger.warn(`[entitlement] default provisioning failed for ${subjectType} ${subjectId}: ${e.message}`);
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { findSubscription, entitlementFor, consume, provisionDefault };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export default { createEntitlementStore };
|