@i4e/invest4edu-access-core 0.6.0 → 0.7.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.6.0",
3
+ "version": "0.7.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": {
@@ -12,7 +12,9 @@
12
12
  "./access-config": "./src/access-config.js",
13
13
  "./access-schema": "./src/access-schema.js",
14
14
  "./access-resolver": "./src/access-resolver.js",
15
- "./visible-when": "./src/visible-when.js"
15
+ "./visible-when": "./src/visible-when.js",
16
+ "./entitlement": "./src/entitlement.js",
17
+ "./entitlement-schema": "./src/entitlement-schema.js"
16
18
  },
17
19
  "scripts": {
18
20
  "test": "node --test test/"
@@ -173,9 +173,16 @@ export function resolveAccessSnapshot({
173
173
  : mergeGrants(grants, { mappedProducts, now });
174
174
 
175
175
  const byCode = new Map(features.map((f) => [f.feature_code, f]));
176
- const productScope = bypass
176
+ // Product scope comes from the IDENTITY, for admins too. The loader already resolves an
177
+ // unmapped identity (internal staff, platform admins) to every product code, so this is "all"
178
+ // for them without any special case. Deriving a bypass admin's scope from the products declared
179
+ // ON FEATURES — as this used to — yields [] whenever no feature declares one, which is the state
180
+ // of the registry today: a system admin resolved to NO products, and every feature then inherited
181
+ // `products: []`. Kept only as a fallback for the case where identity scope is genuinely empty.
182
+ const identityScope = [...new Set(mappedProducts.map(String))];
183
+ const productScope = bypass && !identityScope.length
177
184
  ? [...new Set(features.flatMap((f) => f.products || []))]
178
- : [...new Set(mappedProducts.map(String))];
185
+ : identityScope;
179
186
 
180
187
  const featureMap = {};
181
188
  const allowedScreens = [];
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Entitlement / subscription schema DEFINITIONS — @i4e/invest4edu-access-core (P6).
3
+ *
4
+ * Plain objects like access-schema.js (D4: no Mongoose in the package). Each backend registers its
5
+ * own models from these, so field names cannot drift between v1 and v2.
6
+ *
7
+ * RBAC answers "may they?"; entitlement answers "is it unlocked, and are they in quota?". Both must
8
+ * pass. See TDD §6, and §6.2a for the 2026-07-29 amendment that generalised quota period + subject
9
+ * and added the service-facing meter.
10
+ *
11
+ * Design: nfd-ui-nextjs/IV-CodingAgent/specs/access-management/TDD-unified-access-and-subscription.md
12
+ */
13
+
14
+ /** How often a quota allowance resets. Carried in the period key, so no cron is needed. */
15
+ export const PERIOD_GRANULARITIES = Object.freeze(["day", "month", "year", "term", "lifetime"]);
16
+
17
+ /** Whose allowance is being spent. `account` = per firm (the legacy assumption); `user` = per seat. */
18
+ export const QUOTA_SCOPES = Object.freeze(["account", "user"]);
19
+
20
+ /** What happens when the allowance runs out. */
21
+ export const OVERAGE_POLICIES = Object.freeze(["block", "single_usage", "unlimited"]);
22
+
23
+ export const SUBSCRIPTION_STATUSES = Object.freeze([
24
+ "trialing", "active", "past_due", "blocked", "cancelled", "expired",
25
+ ]);
26
+
27
+ /**
28
+ * The timezone every calendar boundary is evaluated in.
29
+ *
30
+ * A daily quota is meaningless without one: in UTC an Indian partner's "10 a day" would reset at
31
+ * 05:30 local, so they would lose the tail of every working day. Matches the `tz` the MIS pipelines
32
+ * already use.
33
+ */
34
+ export const QUOTA_TIMEZONE = "Asia/Kolkata";
35
+
36
+ /** Reasons an entitlement decision can carry. Stable strings — they surface in APIs and logs. */
37
+ export const ENTITLEMENT_REASONS = Object.freeze({
38
+ OK: "ok",
39
+ NO_SUBSCRIPTION: "no_subscription",
40
+ NOT_IN_PLAN: "not_in_plan",
41
+ LOCKED: "locked",
42
+ QUOTA_EXCEEDED: "quota_exceeded",
43
+ SUBSCRIPTION_BLOCKED: "subscription_blocked",
44
+ ADMIN_BYPASS: "admin_bypass",
45
+ NOT_METERED: "not_metered",
46
+ ENGINE_OFF: "engine_off",
47
+ RESOLVE_FAILED: "resolve_failed",
48
+ });
49
+
50
+ /** Global: the plans on sale. */
51
+ export const PLAN_CATALOG_DEF = Object.freeze({
52
+ collection: "plan_catalog",
53
+ fields: Object.freeze({
54
+ plan_code: { type: "String", required: true, unique: true, index: true, immutable: true },
55
+ name: { type: "String", required: true },
56
+ description: { type: "String" },
57
+ billing_cycle: { type: "String", enum: ["monthly", "quarterly", "annual"], default: "annual" },
58
+ price: { type: "Number", default: 0 },
59
+ currency: { type: "String", default: "INR" },
60
+ position: { type: "Number", default: 0 },
61
+ status: { type: "String", enum: ["active", "dormant", "retired"], default: "active" },
62
+ }),
63
+ });
64
+
65
+ /**
66
+ * Global: `(plan, cycle, feature)` → what that plan grants for that feature.
67
+ *
68
+ * VERSIONED by `effective_date`: resolve the row with the greatest `effective_date <= now`. Prices
69
+ * and quotas change, and a past invoice must remain explicable — so rows are superseded, never
70
+ * edited in place.
71
+ */
72
+ export const PLAN_ENTITLEMENT_DEF = Object.freeze({
73
+ collection: "plan_entitlements",
74
+ fields: Object.freeze({
75
+ plan_code: { type: "String", required: true, index: true },
76
+ billing_cycle: { type: "String", required: true },
77
+ feature_code: { type: "String", required: true, index: true },
78
+
79
+ unlocked: { type: "Boolean", default: true },
80
+ // null = unlimited. 0 is a real value: "in the plan, but no allowance".
81
+ quota: { type: "Number", default: null },
82
+ period_granularity: { type: "String", enum: PERIOD_GRANULARITIES, default: "month" },
83
+ quota_scope: { type: "String", enum: QUOTA_SCOPES, default: "account" },
84
+ overage_policy: { type: "String", enum: OVERAGE_POLICIES, default: "block" },
85
+ single_usage_price: { type: "Number", default: null },
86
+
87
+ effective_date: { type: "Date", required: true, index: true },
88
+ superseded_date: { type: "Date", default: null },
89
+ }),
90
+ indexes: Object.freeze([
91
+ { keys: { plan_code: 1, billing_cycle: 1, feature_code: 1, effective_date: -1 } },
92
+ ]),
93
+ });
94
+
95
+ /** Tenant: one subscription per firm. Replaces AccountPlan + distributor.selected_plan. */
96
+ export const ACCOUNT_SUBSCRIPTION_DEF = Object.freeze({
97
+ collection: "account_subscriptions",
98
+ fields: Object.freeze({
99
+ account_id: { type: "ObjectId", required: true, index: true },
100
+ plan_code: { type: "String", required: true },
101
+ billing_cycle: { type: "String", required: true },
102
+ status: { type: "String", enum: SUBSCRIPTION_STATUSES, default: "trialing", index: true },
103
+ start_date: { type: "Date", required: true },
104
+ end_date: { type: "Date", default: null },
105
+ is_trial: { type: "Boolean", default: false },
106
+ trial_ends_at: { type: "Date", default: null },
107
+ // Per-firm overrides of the plan row: [{ feature_code, quota, unlocked, … }]. Sales concessions
108
+ // belong here, NOT as an edit to the shared plan row.
109
+ entitlement_overrides: { type: "[Mixed]", default: [] },
110
+ }),
111
+ });
112
+
113
+ /**
114
+ * Tenant: the quota counters. MUST live in the app DB — `consume` has to be atomic with the read,
115
+ * so this cannot be a side/analytics store.
116
+ *
117
+ * `subject_key` is the account id or the user id depending on `quota_scope`, so one collection
118
+ * serves both without a second shape.
119
+ */
120
+ export const SUBSCRIPTION_USAGE_DEF = Object.freeze({
121
+ collection: "subscription_usage",
122
+ fields: Object.freeze({
123
+ subscription_id: { type: "ObjectId", required: true, index: true },
124
+ subject_key: { type: "String", required: true },
125
+ feature_code: { type: "String", required: true },
126
+ period: { type: "String", required: true },
127
+ used: { type: "Number", default: 0 },
128
+ last_consumed_at: { type: "Date", default: null },
129
+ }),
130
+ indexes: Object.freeze([
131
+ { keys: { subscription_id: 1, subject_key: 1, feature_code: 1, period: 1 }, options: { unique: true } },
132
+ ]),
133
+ });
134
+
135
+ /**
136
+ * Tenant: the exactly-once ledger for `consume`.
137
+ *
138
+ * A tool that times out and retries must not charge the partner twice, so every consume carries an
139
+ * idempotency key and the unique index — not application logic — is what enforces it.
140
+ */
141
+ export const USAGE_IDEMPOTENCY_DEF = Object.freeze({
142
+ collection: "usage_idempotency",
143
+ fields: Object.freeze({
144
+ idempotency_key: { type: "String", required: true, unique: true, index: true },
145
+ account_id: { type: "ObjectId", index: true },
146
+ feature_code: { type: "String", required: true },
147
+ subject_key: { type: "String" },
148
+ period: { type: "String" },
149
+ n: { type: "Number", default: 1 },
150
+ // The decision returned the first time, replayed verbatim on a duplicate key.
151
+ result: { type: "Mixed", default: null },
152
+ created_date: { type: "Date", default: "now" },
153
+ }),
154
+ });
155
+
156
+ /** Global: the service callers allowed to meter, and which features each may meter. */
157
+ export const METER_CLIENT_DEF = Object.freeze({
158
+ collection: "meter_clients",
159
+ fields: Object.freeze({
160
+ client_code: { type: "String", required: true, unique: true, index: true },
161
+ name: { type: "String", required: true },
162
+ // Key Vault SECRET NAME, never the secret. An admin API must never be able to read it back.
163
+ key_vault_ref: { type: "String", required: true },
164
+ // A tool may only meter what it is registered for — a leaked NFD-AI key cannot spend PFA quota.
165
+ allowed_features: { type: "[String]", default: [] },
166
+ status: { type: "String", enum: ["active", "disabled"], default: "active" },
167
+ }),
168
+ });
169
+
170
+ export default {
171
+ PERIOD_GRANULARITIES,
172
+ QUOTA_SCOPES,
173
+ OVERAGE_POLICIES,
174
+ SUBSCRIPTION_STATUSES,
175
+ QUOTA_TIMEZONE,
176
+ ENTITLEMENT_REASONS,
177
+ PLAN_CATALOG_DEF,
178
+ PLAN_ENTITLEMENT_DEF,
179
+ ACCOUNT_SUBSCRIPTION_DEF,
180
+ SUBSCRIPTION_USAGE_DEF,
181
+ USAGE_IDEMPOTENCY_DEF,
182
+ METER_CLIENT_DEF,
183
+ };
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Entitlement resolution — @i4e/invest4edu-access-core (P6). Pure: no DB, no Mongoose.
3
+ *
4
+ * RBAC says whether a user MAY do a thing. This says whether the FIRM's plan has it unlocked and
5
+ * whether the allowance is spent. Both must pass (TDD §4.2).
6
+ *
7
+ * The one hard rule (§4.3): entitlement **fails open**. An outage in the entitlement path must
8
+ * never block a feature a customer has paid for — the worst acceptable outcome is that we
9
+ * occasionally fail to charge. `overage_policy: 'block'` is the only branch that denies, and only
10
+ * on a definite quota-exceeded answer.
11
+ */
12
+ import {
13
+ ENTITLEMENT_REASONS as R,
14
+ QUOTA_TIMEZONE,
15
+ PERIOD_GRANULARITIES,
16
+ } from "./entitlement-schema.js";
17
+
18
+ export { ENTITLEMENT_REASONS } from "./entitlement-schema.js";
19
+
20
+ /**
21
+ * Calendar parts of `date` in QUOTA_TIMEZONE.
22
+ *
23
+ * Deliberately via Intl rather than date arithmetic: an offset constant would be wrong the moment
24
+ * anything changes, and a naive `toISOString()` would put an Indian partner's daily reset at 05:30
25
+ * local — silently costing them the tail of every working day.
26
+ */
27
+ function partsInZone(date, timeZone = QUOTA_TIMEZONE) {
28
+ const fmt = new Intl.DateTimeFormat("en-CA", {
29
+ timeZone, year: "numeric", month: "2-digit", day: "2-digit",
30
+ });
31
+ const [{ value: y }, , { value: m }, , { value: d }] = fmt.formatToParts(date);
32
+ return { y, m, d };
33
+ }
34
+
35
+ /**
36
+ * The bucket key an allowance is counted in. The granularity lives IN the key, so a reset needs no
37
+ * cron: at midnight the key simply changes and the new bucket starts at zero.
38
+ *
39
+ * @param {string} granularity day | month | year | term | lifetime
40
+ * @param {Object} [opts]
41
+ * @param {Date} [opts.now]
42
+ * @param {string} [opts.subscriptionId] required for `term`
43
+ * @param {string} [opts.timeZone]
44
+ */
45
+ export function periodKey(granularity, { now = new Date(), subscriptionId = null, timeZone } = {}) {
46
+ const g = PERIOD_GRANULARITIES.includes(granularity) ? granularity : "month";
47
+ if (g === "lifetime") return "lifetime";
48
+ // A term quota with no subscription would silently collapse every firm into one shared bucket.
49
+ if (g === "term") return subscriptionId ? `term:${String(subscriptionId)}` : "lifetime";
50
+
51
+ const { y, m, d } = partsInZone(now, timeZone);
52
+ if (g === "year") return y;
53
+ if (g === "month") return `${y}-${m}`;
54
+ return `${y}-${m}-${d}`;
55
+ }
56
+
57
+ /** Whose allowance this is: the firm, or the individual seat. */
58
+ export function subjectKeyFor(quotaScope, { accountId, userId } = {}) {
59
+ return String((quotaScope === "user" ? userId : accountId) ?? "");
60
+ }
61
+
62
+ /**
63
+ * Pick the live entitlement row for a feature: greatest `effective_date <= now`, not superseded.
64
+ * Rows are versioned rather than edited so a past invoice stays explicable.
65
+ */
66
+ export function resolveEntitlementRow(rows = [], featureCode, now = new Date()) {
67
+ return rows
68
+ .filter((r) => r
69
+ && r.feature_code === featureCode
70
+ && new Date(r.effective_date) <= now
71
+ && (!r.superseded_date || new Date(r.superseded_date) > now))
72
+ .sort((a, b) => new Date(b.effective_date) - new Date(a.effective_date))[0] || null;
73
+ }
74
+
75
+ /** Apply a per-firm override on top of the plan row. Concessions live on the subscription. */
76
+ function withOverride(row, overrides = [], featureCode) {
77
+ const o = (overrides || []).find((x) => x && x.feature_code === featureCode);
78
+ if (!o) return row;
79
+ const merged = { ...row };
80
+ for (const k of ["unlocked", "quota", "period_granularity", "quota_scope", "overage_policy", "single_usage_price"]) {
81
+ if (o[k] !== undefined) merged[k] = o[k];
82
+ }
83
+ return merged;
84
+ }
85
+
86
+ /**
87
+ * Decide entitlement for one feature.
88
+ *
89
+ * @param {Object} p
90
+ * @param {Object|null} p.subscription account_subscriptions row
91
+ * @param {Array} p.entitlementRows plan_entitlements rows for this plan+cycle
92
+ * @param {string} p.featureCode
93
+ * @param {Object} p.feature registry row — `is_meterable` decides if quota applies
94
+ * @param {number} p.used already consumed in this period (caller supplies)
95
+ * @param {Object} p.identity { accountId, userId }
96
+ * @param {boolean} [p.bypass] D27 admin break-glass
97
+ * @param {Date} [p.now]
98
+ * @returns {Object} { unlocked, quota, used, remaining, allowed, overage_policy, reason,
99
+ * period, subject_key, metered }
100
+ */
101
+ export function resolveEntitlement({
102
+ subscription, entitlementRows = [], featureCode, feature = {},
103
+ used = 0, identity = {}, bypass = false, now = new Date(),
104
+ } = {}) {
105
+ const base = {
106
+ quota: null, used, remaining: null, period: null, subject_key: null,
107
+ overage_policy: "unlimited", metered: false,
108
+ };
109
+ const open = (reason) => ({ ...base, unlocked: true, allowed: true, reason });
110
+
111
+ // D27: the break-glass resolves every gate open, including this one.
112
+ if (bypass) return open(R.ADMIN_BYPASS);
113
+
114
+ // `always_available` exists so login/billing/profile survive a blocked subscription — otherwise a
115
+ // past-due firm could not reach the screen that takes their money.
116
+ if (feature.always_available) return open(R.OK);
117
+
118
+ // No subscription: fail OPEN. Absence of a row is far more likely to be our gap than a customer
119
+ // genuinely owning nothing, and denying would break every un-migrated firm on day one.
120
+ if (!subscription) return open(R.NO_SUBSCRIPTION);
121
+
122
+ if (subscription.status === "blocked" || subscription.status === "expired") {
123
+ return { ...base, unlocked: false, allowed: false, reason: R.SUBSCRIPTION_BLOCKED };
124
+ }
125
+
126
+ const row = withOverride(
127
+ resolveEntitlementRow(entitlementRows, featureCode, now),
128
+ subscription.entitlement_overrides,
129
+ featureCode,
130
+ );
131
+ // Not named in the plan: OPEN. A plan that forgot to list a feature is a config gap, and P6 must
132
+ // not silently switch features off for everyone the moment it ships.
133
+ if (!row) return open(R.NOT_IN_PLAN);
134
+
135
+ if (row.unlocked === false) {
136
+ return { ...base, unlocked: false, allowed: false, overage_policy: row.overage_policy, reason: R.LOCKED };
137
+ }
138
+
139
+ // Unlocked but not metered → nothing to count.
140
+ if (!feature.is_meterable || row.quota === null || row.quota === undefined) {
141
+ return { ...open(feature.is_meterable ? R.OK : R.NOT_METERED), overage_policy: row.overage_policy || "unlimited" };
142
+ }
143
+
144
+ const granularity = row.period_granularity || "month";
145
+ const period = periodKey(granularity, { now, subscriptionId: subscription._id });
146
+ const subject_key = subjectKeyFor(row.quota_scope, identity);
147
+ const remaining = Math.max(0, Number(row.quota) - Number(used || 0));
148
+ const withinQuota = Number(used || 0) < Number(row.quota);
149
+ const policy = row.overage_policy || "block";
150
+
151
+ return {
152
+ unlocked: true,
153
+ quota: Number(row.quota),
154
+ used: Number(used || 0),
155
+ remaining,
156
+ // Only `block` denies. single_usage/unlimited let the call through — billing catches up after.
157
+ allowed: withinQuota || policy !== "block",
158
+ overage_policy: policy,
159
+ reason: withinQuota ? R.OK : R.QUOTA_EXCEEDED,
160
+ period,
161
+ subject_key,
162
+ metered: true,
163
+ };
164
+ }
165
+
166
+ export default { resolveEntitlement, resolveEntitlementRow, periodKey, subjectKeyFor };