@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@i4e/invest4edu-access-core",
3
- "version": "0.12.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 };