@i4e/invest4edu-access-core 0.11.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 +4 -2
- package/src/entitlement-store.js +217 -0
- 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.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": {
|
|
@@ -16,7 +16,9 @@
|
|
|
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",
|
|
21
|
+
"./entitlement-store": "./src/entitlement-store.js"
|
|
20
22
|
},
|
|
21
23
|
"scripts": {
|
|
22
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 };
|
|
@@ -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 };
|