@i4e/invest4edu-access-core 0.12.0 → 0.13.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.13.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,217 @@
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
+ return { findSubscription, entitlementFor, consume };
215
+ }
216
+
217
+ export default { createEntitlementStore };