@prsm/entitle 1.1.0 → 2.0.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/README.md CHANGED
@@ -30,6 +30,8 @@ const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
30
30
  const entitlements = createEntitlements({
31
31
  driver: postgresDriver({ pool }),
32
32
  defaultPlan: "free",
33
+ features: ["api_access", "export_csv", "sso"], // the universe of valid feature keys
34
+ limits: ["tokens", "seats"], // the universe of valid limit keys
33
35
  plans: {
34
36
  free: {
35
37
  features: { api_access: true, export_csv: false, sso: false },
@@ -49,6 +51,8 @@ const entitlements = createEntitlements({
49
51
  await entitlements.setup() // create tables if they do not exist; idempotent
50
52
  ```
51
53
 
54
+ Declaring `features` and `limits` up front is the catalog: plans may only reference keys in it, and `can()`/`limit()`/`check()` throw on any other key, so a typo (`can(id, "exprot_csv")`) surfaces instead of silently returning `false`. The declarations are optional - if you omit them the universe is derived from the union of keys across your plans - but declaring them also catches typos in the plan definitions themselves.
55
+
52
56
  Gating a feature and a quota on a request:
53
57
 
54
58
  ```js
@@ -95,14 +99,23 @@ if (!quota.allowed) throw new Error("monthly token limit reached")
95
99
 
96
100
  Because the limit is resolved from the plan and the usage is read from the meter on every call, the same code path enforces a tightened plan, a granted override, and a depleting quota without any of it being baked in at startup.
97
101
 
102
+ ### Who owns what
103
+
104
+ Entitle does not track usage. It has no usage state of its own. In the result above, `limit: 5000000` comes from the plan (entitle's only input), `used: 84210` comes from a single live `meter.usage()` call that `check()` makes for you, and `remaining` is just `limit - used`. All accrual happens in your application calling `meter.record(...)` on each usage event; meter stores and aggregates it. So meter owns "how much have they used," entitle owns "what is the ceiling," and `check()` fetches the ceiling, asks meter for the usage, and subtracts. Pass `meter` only to enable that one call - without it, use `limit()` for the ceiling and read usage from meter yourself.
105
+
106
+ Two things must line up for `check()` to work:
107
+
108
+ - **Same subject identifier.** `check(subject, key)` calls `meter.usage({ subject, ... })`, so the `subject` you pass here must be the same id you record usage under in meter.
109
+ - **Matching key.** The entitle limit key must equal the meter metric name (`check(id, "tokens")` reads the `tokens` metric). If the metric does not exist in the meter, meter throws.
110
+
98
111
  ## Plans, assignments, and overrides
99
112
 
100
113
  The **plan catalog** is declared once at construction, the way you declare your pricing tiers in code. A plan grants:
101
114
 
102
115
  - **features** - a map of capability flags (`{ sso: true, export_csv: false }`), read with `can()`.
103
- - **limits** - a map of numeric ceilings keyed by name (`{ tokens: 5_000_000, seats: 10 }`), read with `limit()` and enforced with `check()`. A `null` limit means unlimited.
116
+ - **limits** - a map of numeric ceilings keyed by name (`{ tokens: 5_000_000, seats: 10 }`), read with `limit()` and enforced with `check()`. A `null` limit means unlimited; a known limit a plan does not grant resolves to `0` (no allowance), never silently unlimited.
104
117
 
105
- **Assignments** (which plan a subject is on) and **overrides** (per-subject adjustments) live in postgres and are mutable at runtime. An override shallow-merges over the plan, so you specify only what differs. A subject with no assignment gets `defaultPlan`.
118
+ **Assignments** (which plan a subject is on) and **overrides** (per-subject adjustments) live in postgres and are mutable at runtime. An override shallow-merges over the plan, so you specify only what differs. A subject with no assignment gets `defaultPlan`; `unassign(subject)` removes an assignment and reverts the subject to the default (distinct from assigning the default explicitly, which would not follow a later change to `defaultPlan`).
106
119
 
107
120
  Overrides accumulate: each `override()` call merges into the subject's existing override rather than replacing it, so granting more seats and later enabling a feature leaves both in place, and overriding a key again updates just that key. `clearOverride(subject)` removes the whole override; `clearOverride(subject, { limits: ["seats"] })` reverts only the named keys and keeps the rest.
108
121
 
@@ -117,9 +130,9 @@ Resolved entitlements are cached per subject for a short, configurable window (`
117
130
 
118
131
  ## API
119
132
 
120
- ### `createEntitlements({ driver, plans, defaultPlan, meter?, cacheTtl?, tracer? })`
133
+ ### `createEntitlements({ driver, plans, defaultPlan, features?, limits?, meter?, cacheTtl?, tracer? })`
121
134
 
122
- Creates a resolver. `driver` is `postgresDriver({ pool })` or `memoryDriver()`. `plans` is the catalog; `defaultPlan` must be one of its keys. `meter` is an optional `@prsm/meter` instance, required only for `check()`. `cacheTtl` accepts ms or a string like `"10s"`. `tracer` is an optional `@prsm/trace` tracer.
135
+ Creates a resolver. `driver` is `postgresDriver({ pool })` or `memoryDriver()`. `plans` is the catalog; `defaultPlan` must be one of its keys. `features` and `limits` declare the universe of valid keys (defaulting to the union across plans); when given, plans may only reference declared keys. `meter` is an optional `@prsm/meter` instance, required only for `check()`. `cacheTtl` accepts ms or a string like `"10s"`. `tracer` is an optional `@prsm/trace` tracer.
123
136
 
124
137
  ### `entitlements.setup()`
125
138
 
@@ -129,6 +142,10 @@ Creates the backing tables if they do not exist. Idempotent.
129
142
 
130
143
  Assigns a subject to a plan. Takes effect immediately.
131
144
 
145
+ ### `entitlements.unassign(subject)`
146
+
147
+ Removes a subject's assignment, reverting them to the default plan.
148
+
132
149
  ### `entitlements.override(subject, { features?, limits? })`
133
150
 
134
151
  Layers a per-subject override on top of the plan. Shallow-merges; pass only what differs.
@@ -139,11 +156,11 @@ With no `keys`, removes the subject's entire override. With `keys` (`{ features?
139
156
 
140
157
  ### `entitlements.can(subject, feature)`
141
158
 
142
- Returns whether a capability flag is granted (`false` for an ungranted or unknown feature).
159
+ Returns whether a capability flag is granted (`false` for an ungranted feature). Throws on a feature key outside the catalog, so typos surface instead of silently returning `false`.
143
160
 
144
161
  ### `entitlements.limit(subject, key)`
145
162
 
146
- Returns the numeric ceiling for a limit key after overrides, or `null` for an unlimited or undeclared limit. Does not read usage.
163
+ Returns the numeric ceiling for a limit key after overrides: `null` only for an explicitly unlimited limit, `0` for a known limit the plan does not grant. Throws on a key outside the catalog. Does not read usage.
147
164
 
148
165
  ### `entitlements.check(subject, key, usageQuery?)`
149
166
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prsm/entitle",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Plan-based entitlements and feature gating, resolved at runtime, backed by postgres",
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,6 +20,8 @@ import ms from "@prsm/ms"
20
20
  * @property {Object} driver - storage backend: `postgresDriver({ pool })` for production, `memoryDriver()` for tests
21
21
  * @property {Record<string, Plan>} plans - the plan catalog, declared once at construction (your pricing tiers)
22
22
  * @property {string} defaultPlan - plan applied to a subject with no assignment; must be a key of `plans`
23
+ * @property {string[]} [features] - the universe of valid feature keys. When given, plans may only reference these and `can()` throws on any other key (catches typos). Defaults to the union of feature keys across all plans
24
+ * @property {string[]} [limits] - the universe of valid limit keys. When given, plans may only reference these and `limit()`/`check()` throw on any other key. Defaults to the union of limit keys across all plans
23
25
  * @property {{ usage: Function }} [meter] - optional `@prsm/meter` instance; required only for `check()`, which reads live usage from it
24
26
  * @property {number|string} [cacheTtl] - how long resolved plan/override state is cached per subject, ms or a string like `"10s"` (default `"10s"`, `0` disables). Kept short and invalidated on writes so entitlements stay runtime-evaluated, never startup-frozen
25
27
  * @property {{ startSpan: Function }} [tracer] - optional `@prsm/trace` tracer
@@ -42,26 +44,60 @@ import ms from "@prsm/ms"
42
44
  * @property {string} feature - the limit key that was checked
43
45
  */
44
46
 
45
- function validatePlans(plans, defaultPlan) {
47
+ function assertBoolean(scope, key, v) {
48
+ if (typeof v !== "boolean") throw new Error(`${scope} feature "${key}" must be a boolean`)
49
+ }
50
+
51
+ function assertLimitValue(scope, key, v) {
52
+ if (v !== null && (typeof v !== "number" || !Number.isFinite(v))) {
53
+ throw new Error(`${scope} limit "${key}" must be a finite number or null (null means unlimited)`)
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Validate the plan catalog, derive or enforce the feature/limit universes, and
59
+ * type-check every plan value. Returns the two universes as Sets.
60
+ */
61
+ function buildCatalog(plans, defaultPlan, declaredFeatures, declaredLimits) {
46
62
  if (!plans || typeof plans !== "object" || Object.keys(plans).length === 0) {
47
63
  throw new Error("createEntitlements requires a non-empty `plans` catalog")
48
64
  }
65
+ if (!defaultPlan || !plans[defaultPlan]) {
66
+ throw new Error(`createEntitlements requires \`defaultPlan\` to be one of the declared plans: ${Object.keys(plans).join(", ")}`)
67
+ }
68
+
69
+ const featureUniverse = new Set(declaredFeatures ?? [])
70
+ const limitUniverse = new Set(declaredLimits ?? [])
71
+ const enforceFeatures = Array.isArray(declaredFeatures)
72
+ const enforceLimits = Array.isArray(declaredLimits)
73
+
49
74
  for (const [name, plan] of Object.entries(plans)) {
50
75
  if (plan.features && typeof plan.features !== "object") throw new Error(`plan "${name}" features must be an object`)
51
76
  if (plan.limits && typeof plan.limits !== "object") throw new Error(`plan "${name}" limits must be an object`)
77
+ for (const [k, v] of Object.entries(plan.features ?? {})) {
78
+ assertBoolean(`plan "${name}"`, k, v)
79
+ if (enforceFeatures && !featureUniverse.has(k)) throw new Error(`plan "${name}" references undeclared feature "${k}"`)
80
+ featureUniverse.add(k)
81
+ }
82
+ for (const [k, v] of Object.entries(plan.limits ?? {})) {
83
+ assertLimitValue(`plan "${name}"`, k, v)
84
+ if (enforceLimits && !limitUniverse.has(k)) throw new Error(`plan "${name}" references undeclared limit "${k}"`)
85
+ limitUniverse.add(k)
86
+ }
52
87
  }
53
- if (!defaultPlan || !plans[defaultPlan]) {
54
- throw new Error(`createEntitlements requires \`defaultPlan\` to be one of the declared plans: ${Object.keys(plans).join(", ")}`)
55
- }
88
+
89
+ return { featureUniverse, limitUniverse }
56
90
  }
57
91
 
58
- function validateOverride(data) {
92
+ function validateOverride(data, featureUniverse, limitUniverse) {
59
93
  if (!data || typeof data !== "object") throw new Error("override data must be an object with `features` and/or `limits`")
60
94
  for (const [k, v] of Object.entries(data.features ?? {})) {
61
- if (typeof v !== "boolean") throw new Error(`override feature "${k}" must be a boolean`)
95
+ assertBoolean("override", k, v)
96
+ if (!featureUniverse.has(k)) throw new Error(`override references unknown feature "${k}"`)
62
97
  }
63
98
  for (const [k, v] of Object.entries(data.limits ?? {})) {
64
- if (v !== null && (typeof v !== "number" || !Number.isFinite(v))) throw new Error(`override limit "${k}" must be a finite number or null`)
99
+ assertLimitValue("override", k, v)
100
+ if (!limitUniverse.has(k)) throw new Error(`override references unknown limit "${k}"`)
65
101
  }
66
102
  }
67
103
 
@@ -111,11 +147,22 @@ async function traced(tracer, name, attrs, fn) {
111
147
  export function createEntitlements(options = {}) {
112
148
  const { driver, plans, defaultPlan, meter = null, tracer = null } = options
113
149
  if (!driver) throw new Error("createEntitlements requires a `driver` (postgresDriver or memoryDriver)")
114
- validatePlans(plans, defaultPlan)
150
+ const { featureUniverse, limitUniverse } = buildCatalog(plans, defaultPlan, options.features, options.limits)
115
151
 
116
152
  const catalog = { ...plans }
117
153
  const cache = createCache(ms(options.cacheTtl ?? "10s"))
118
154
 
155
+ function requireFeature(feature) {
156
+ if (!featureUniverse.has(feature)) {
157
+ throw new Error(`unknown feature "${feature}". declared features: ${[...featureUniverse].join(", ") || "(none)"}`)
158
+ }
159
+ }
160
+ function requireLimit(key) {
161
+ if (!limitUniverse.has(key)) {
162
+ throw new Error(`unknown limit "${key}". declared limits: ${[...limitUniverse].join(", ") || "(none)"}`)
163
+ }
164
+ }
165
+
119
166
  async function resolve(subject) {
120
167
  if (!subject) throw new Error("a `subject` is required")
121
168
  const cached = cache.get(subject)
@@ -155,6 +202,18 @@ export function createEntitlements(options = {}) {
155
202
  cache.invalidate(subject)
156
203
  },
157
204
 
205
+ /**
206
+ * Remove a subject's plan assignment, reverting them to the default plan.
207
+ * Distinct from assigning the default plan explicitly: an unassigned subject
208
+ * follows `defaultPlan` if it later changes.
209
+ * @param {string} subject
210
+ */
211
+ async unassign(subject) {
212
+ if (!subject) throw new Error("unassign requires a `subject`")
213
+ await driver.unassign(subject)
214
+ cache.invalidate(subject)
215
+ },
216
+
158
217
  /**
159
218
  * Add or adjust a per-subject override, layered on top of the subject's plan.
160
219
  * Merges into any existing override for the subject, so repeated calls
@@ -166,7 +225,7 @@ export function createEntitlements(options = {}) {
166
225
  */
167
226
  async override(subject, data) {
168
227
  if (!subject) throw new Error("override requires a `subject`")
169
- validateOverride(data)
228
+ validateOverride(data, featureUniverse, limitUniverse)
170
229
  const current = (await driver.getState(subject))?.override ?? {}
171
230
  await driver.setOverride(subject, {
172
231
  features: { ...(current.features ?? {}), ...(data.features ?? {}) },
@@ -214,12 +273,15 @@ export function createEntitlements(options = {}) {
214
273
  },
215
274
 
216
275
  /**
217
- * Whether a capability flag is granted to the subject.
276
+ * Whether a capability flag is granted to the subject. Throws on a feature
277
+ * key outside the declared/derived universe, so typos surface instead of
278
+ * silently returning false.
218
279
  * @param {string} subject
219
280
  * @param {string} feature
220
281
  * @returns {Promise<boolean>}
221
282
  */
222
283
  async can(subject, feature) {
284
+ requireFeature(feature)
223
285
  return traced(tracer, "entitle.can", { "entitle.subject": subject, "entitle.feature": feature }, async () => {
224
286
  const eff = await resolve(subject)
225
287
  return eff.features[feature] === true
@@ -227,15 +289,19 @@ export function createEntitlements(options = {}) {
227
289
  },
228
290
 
229
291
  /**
230
- * The numeric ceiling for a limit key, after overrides. Returns `null` for an
231
- * unlimited or undeclared limit. This is the static ceiling; it does not read usage.
292
+ * The numeric ceiling for a limit key, after overrides. Returns `null` only
293
+ * when the limit is explicitly unlimited, and `0` for a known limit the
294
+ * subject's plan does not grant - never silently unlimited. Throws on a key
295
+ * outside the declared/derived universe. This is the static ceiling; it does
296
+ * not read usage.
232
297
  * @param {string} subject
233
298
  * @param {string} key
234
299
  * @returns {Promise<number|null>}
235
300
  */
236
301
  async limit(subject, key) {
302
+ requireLimit(key)
237
303
  const eff = await resolve(subject)
238
- return key in eff.limits ? eff.limits[key] : null
304
+ return key in eff.limits ? eff.limits[key] : 0
239
305
  },
240
306
 
241
307
  /**
@@ -254,7 +320,7 @@ export function createEntitlements(options = {}) {
254
320
  }
255
321
  return traced(tracer, "entitle.check", { "entitle.subject": subject, "entitle.feature": key }, async () => {
256
322
  const limit = await this.limit(subject, key)
257
- const usage = await meter.usage({ subject, metric: key, ...usageQuery })
323
+ const usage = await meter.usage({ ...usageQuery, subject, metric: key })
258
324
  const used = usage.quantity
259
325
  const allowed = limit === null || used < limit
260
326
  return {
@@ -23,6 +23,10 @@ export function memoryDriver() {
23
23
  assignments.set(subject, plan)
24
24
  },
25
25
 
26
+ async unassign(subject) {
27
+ assignments.delete(subject)
28
+ },
29
+
26
30
  async setOverride(subject, data) {
27
31
  overrides.set(subject, data)
28
32
  },
@@ -52,6 +52,10 @@ export function postgresDriver(options = {}) {
52
52
  )
53
53
  },
54
54
 
55
+ async unassign(subject) {
56
+ await pool.query(`delete from ${assignments} where subject = $1`, [subject])
57
+ },
58
+
55
59
  async setOverride(subject, data) {
56
60
  await pool.query(
57
61
  `insert into ${overrides} (subject, data) values ($1, $2)
@@ -15,6 +15,13 @@ export function createEntitlements(options?: EntitlementsOptions): {
15
15
  * @param {string} plan - a key of the plan catalog
16
16
  */
17
17
  assign(subject: string, plan: string): Promise<void>;
18
+ /**
19
+ * Remove a subject's plan assignment, reverting them to the default plan.
20
+ * Distinct from assigning the default plan explicitly: an unassigned subject
21
+ * follows `defaultPlan` if it later changes.
22
+ * @param {string} subject
23
+ */
24
+ unassign(subject: string): Promise<void>;
18
25
  /**
19
26
  * Add or adjust a per-subject override, layered on top of the subject's plan.
20
27
  * Merges into any existing override for the subject, so repeated calls
@@ -43,15 +50,20 @@ export function createEntitlements(options?: EntitlementsOptions): {
43
50
  */
44
51
  plan(subject: string): Promise<string>;
45
52
  /**
46
- * Whether a capability flag is granted to the subject.
53
+ * Whether a capability flag is granted to the subject. Throws on a feature
54
+ * key outside the declared/derived universe, so typos surface instead of
55
+ * silently returning false.
47
56
  * @param {string} subject
48
57
  * @param {string} feature
49
58
  * @returns {Promise<boolean>}
50
59
  */
51
60
  can(subject: string, feature: string): Promise<boolean>;
52
61
  /**
53
- * The numeric ceiling for a limit key, after overrides. Returns `null` for an
54
- * unlimited or undeclared limit. This is the static ceiling; it does not read usage.
62
+ * The numeric ceiling for a limit key, after overrides. Returns `null` only
63
+ * when the limit is explicitly unlimited, and `0` for a known limit the
64
+ * subject's plan does not grant - never silently unlimited. Throws on a key
65
+ * outside the declared/derived universe. This is the static ceiling; it does
66
+ * not read usage.
55
67
  * @param {string} subject
56
68
  * @param {string} key
57
69
  * @returns {Promise<number|null>}
@@ -112,6 +124,14 @@ export type EntitlementsOptions = {
112
124
  * - plan applied to a subject with no assignment; must be a key of `plans`
113
125
  */
114
126
  defaultPlan: string;
127
+ /**
128
+ * - the universe of valid feature keys. When given, plans may only reference these and `can()` throws on any other key (catches typos). Defaults to the union of feature keys across all plans
129
+ */
130
+ features?: string[];
131
+ /**
132
+ * - the universe of valid limit keys. When given, plans may only reference these and `limit()`/`check()` throw on any other key. Defaults to the union of limit keys across all plans
133
+ */
134
+ limits?: string[];
115
135
  /**
116
136
  * - optional `@prsm/meter` instance; required only for `check()`, which reads live usage from it
117
137
  */