@i4e/invest4edu-access-core 0.24.0 → 0.25.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/entitlement.js +29 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@i4e/invest4edu-access-core",
3
- "version": "0.24.0",
3
+ "version": "0.25.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": {
@@ -144,6 +144,12 @@ export function resolveEntitlement({
144
144
  const base = {
145
145
  quota: null, used, remaining: null, period: null, subject_key: null,
146
146
  overage_policy: "unlimited", metered: false,
147
+ /**
148
+ * A CEILING that shapes a response — top-N picks, tips per answer — as opposed to `quota`,
149
+ * which is a budget that decrements. Null when the plan sets none. Present on every result so
150
+ * a caller never has to tell "no ceiling" from "this resolver is too old to send one".
151
+ */
152
+ limit: null,
147
153
  };
148
154
  const open = (reason) => ({ ...base, unlocked: true, allowed: true, reason });
149
155
 
@@ -211,8 +217,28 @@ export function resolveEntitlement({
211
217
  }
212
218
 
213
219
  // Unlocked but not metered → nothing to count.
220
+ /**
221
+ * Unlocked but not counted.
222
+ *
223
+ * `limit` is carried through even though nothing is metered, because a number on an unmetered
224
+ * row is a CEILING rather than a budget: "your top-picks list shows 5" is read on every request
225
+ * and never decrements. Returning it lets a caller shape its response from the plan instead of
226
+ * hard-coding the shape, which is the difference between a config edit and a deploy.
227
+ *
228
+ * `quota` deliberately stays null here — it means "how much is left", and nothing is being
229
+ * spent. Reusing it for a ceiling would make `remaining` a lie and invite a consume() call that
230
+ * should never happen.
231
+ */
214
232
  if (!feature.is_meterable || row.quota === null || row.quota === undefined) {
215
- return { ...open(feature.is_meterable ? R.OK : R.NOT_METERED), overage_policy: row.overage_policy || "unlimited" };
233
+ // null/undefined is UNSET, and Number() turns both into 0 — which would read as "show
234
+ // nothing", the exact opposite. Only a real number is a ceiling.
235
+ const raw = row.quota;
236
+ const ceiling = raw === null || raw === undefined || raw === "" ? null : Number(raw);
237
+ return {
238
+ ...open(feature.is_meterable ? R.OK : R.NOT_METERED),
239
+ overage_policy: row.overage_policy || "unlimited",
240
+ limit: ceiling !== null && Number.isFinite(ceiling) && ceiling >= 0 ? ceiling : null,
241
+ };
216
242
  }
217
243
 
218
244
  const granularity = row.period_granularity || "month";
@@ -252,6 +278,8 @@ export function resolveEntitlement({
252
278
 
253
279
  return {
254
280
  unlocked: true,
281
+ // Declared even on a metered result: absence must never be mistaken for "no ceiling".
282
+ limit: null,
255
283
  quota: Number(row.quota),
256
284
  used: Number(used || 0),
257
285
  remaining,