@i4e/invest4edu-access-core 0.25.0 → 0.26.1

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.25.0",
3
+ "version": "0.26.1",
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": {
@@ -81,6 +81,13 @@ export interface EntitlementStore {
81
81
  }
82
82
 
83
83
  export function createEntitlementStore(opts: {
84
+ /**
85
+ * The subscription master switch. Returning false makes the whole store inert — every
86
+ * entitlement answers OPEN, nothing is counted and no subject is auto-provisioned — so the code
87
+ * can ship to production long before the product does. Defaults to the `SUBSCRIPTION_ENABLED`
88
+ * environment variable, and therefore to OFF.
89
+ */
90
+ isEnabled?: () => boolean | Promise<boolean>;
84
91
  /** Returns a raw MongoDB `Db`, or null/undefined when the connection is not ready. */
85
92
  getDb: () => unknown;
86
93
  /** Cast an id the way the caller's driver expects. Defaults to pass-through. */
@@ -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({ getDb, toObjectId = (v) => v, logger = noopLogger } = {}) {
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