@prsm/entitle 1.0.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,22 +99,40 @@ 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.
117
+
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`).
104
119
 
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`.
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.
121
+
122
+ ```js
123
+ await entitlements.override(account.id, { limits: { seats: 50 } })
124
+ await entitlements.override(account.id, { features: { sso: true } }) // seats override stays
125
+ await entitlements.clearOverride(account.id, { limits: ["seats"] }) // seats reverts to plan, sso stays
126
+ await entitlements.clearOverride(account.id) // back to plain plan
127
+ ```
106
128
 
107
129
  Resolved entitlements are cached per subject for a short, configurable window (`cacheTtl`, default `"10s"`) and the cache is invalidated immediately on `assign`, `override`, and `clearOverride`, so the instance making a change sees it at once and other instances converge within the TTL. Set `cacheTtl: 0` to read postgres on every call.
108
130
 
109
131
  ## API
110
132
 
111
- ### `createEntitlements({ driver, plans, defaultPlan, meter?, cacheTtl?, tracer? })`
133
+ ### `createEntitlements({ driver, plans, defaultPlan, features?, limits?, meter?, cacheTtl?, tracer? })`
112
134
 
113
- 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.
114
136
 
115
137
  ### `entitlements.setup()`
116
138
 
@@ -120,21 +142,25 @@ Creates the backing tables if they do not exist. Idempotent.
120
142
 
121
143
  Assigns a subject to a plan. Takes effect immediately.
122
144
 
145
+ ### `entitlements.unassign(subject)`
146
+
147
+ Removes a subject's assignment, reverting them to the default plan.
148
+
123
149
  ### `entitlements.override(subject, { features?, limits? })`
124
150
 
125
151
  Layers a per-subject override on top of the plan. Shallow-merges; pass only what differs.
126
152
 
127
- ### `entitlements.clearOverride(subject)`
153
+ ### `entitlements.clearOverride(subject, keys?)`
128
154
 
129
- Removes the subject's override.
155
+ With no `keys`, removes the subject's entire override. With `keys` (`{ features?: string[], limits?: string[] }`), removes only those entries and keeps the rest.
130
156
 
131
157
  ### `entitlements.can(subject, feature)`
132
158
 
133
- 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`.
134
160
 
135
161
  ### `entitlements.limit(subject, key)`
136
162
 
137
- 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.
138
164
 
139
165
  ### `entitlements.check(subject, key, usageQuery?)`
140
166
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prsm/entitle",
3
- "version": "1.0.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)
@@ -156,25 +203,63 @@ export function createEntitlements(options = {}) {
156
203
  },
157
204
 
158
205
  /**
159
- * Layer a per-subject override on top of the subject's plan. Shallow-merges,
160
- * so pass only what differs. Takes effect immediately.
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
+
217
+ /**
218
+ * Add or adjust a per-subject override, layered on top of the subject's plan.
219
+ * Merges into any existing override for the subject, so repeated calls
220
+ * accumulate: overriding `seats`, then later overriding `sso`, leaves both in
221
+ * place, and overriding a key again updates just that key. Use clearOverride
222
+ * to remove overrides. Takes effect immediately.
161
223
  * @param {string} subject
162
224
  * @param {Override} data
163
225
  */
164
226
  async override(subject, data) {
165
227
  if (!subject) throw new Error("override requires a `subject`")
166
- validateOverride(data)
167
- await driver.setOverride(subject, { features: data.features ?? {}, limits: data.limits ?? {} })
228
+ validateOverride(data, featureUniverse, limitUniverse)
229
+ const current = (await driver.getState(subject))?.override ?? {}
230
+ await driver.setOverride(subject, {
231
+ features: { ...(current.features ?? {}), ...(data.features ?? {}) },
232
+ limits: { ...(current.limits ?? {}), ...(data.limits ?? {}) },
233
+ })
168
234
  cache.invalidate(subject)
169
235
  },
170
236
 
171
237
  /**
172
- * Remove a subject's override, falling back to plain plan entitlements.
238
+ * Remove overrides for a subject. With no `keys`, removes the entire override
239
+ * and the subject falls back to plain plan entitlements. With `keys`, removes
240
+ * only those override entries (reverting them to the plan) and keeps the rest.
173
241
  * @param {string} subject
242
+ * @param {{ features?: string[], limits?: string[] }} [keys]
174
243
  */
175
- async clearOverride(subject) {
244
+ async clearOverride(subject, keys) {
176
245
  if (!subject) throw new Error("clearOverride requires a `subject`")
177
- await driver.clearOverride(subject)
246
+ if (!keys) {
247
+ await driver.clearOverride(subject)
248
+ cache.invalidate(subject)
249
+ return
250
+ }
251
+ const current = (await driver.getState(subject))?.override
252
+ if (current) {
253
+ const features = { ...(current.features ?? {}) }
254
+ const limits = { ...(current.limits ?? {}) }
255
+ for (const k of keys.features ?? []) delete features[k]
256
+ for (const k of keys.limits ?? []) delete limits[k]
257
+ if (Object.keys(features).length === 0 && Object.keys(limits).length === 0) {
258
+ await driver.clearOverride(subject)
259
+ } else {
260
+ await driver.setOverride(subject, { features, limits })
261
+ }
262
+ }
178
263
  cache.invalidate(subject)
179
264
  },
180
265
 
@@ -188,12 +273,15 @@ export function createEntitlements(options = {}) {
188
273
  },
189
274
 
190
275
  /**
191
- * 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.
192
279
  * @param {string} subject
193
280
  * @param {string} feature
194
281
  * @returns {Promise<boolean>}
195
282
  */
196
283
  async can(subject, feature) {
284
+ requireFeature(feature)
197
285
  return traced(tracer, "entitle.can", { "entitle.subject": subject, "entitle.feature": feature }, async () => {
198
286
  const eff = await resolve(subject)
199
287
  return eff.features[feature] === true
@@ -201,15 +289,19 @@ export function createEntitlements(options = {}) {
201
289
  },
202
290
 
203
291
  /**
204
- * The numeric ceiling for a limit key, after overrides. Returns `null` for an
205
- * 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.
206
297
  * @param {string} subject
207
298
  * @param {string} key
208
299
  * @returns {Promise<number|null>}
209
300
  */
210
301
  async limit(subject, key) {
302
+ requireLimit(key)
211
303
  const eff = await resolve(subject)
212
- return key in eff.limits ? eff.limits[key] : null
304
+ return key in eff.limits ? eff.limits[key] : 0
213
305
  },
214
306
 
215
307
  /**
@@ -228,7 +320,7 @@ export function createEntitlements(options = {}) {
228
320
  }
229
321
  return traced(tracer, "entitle.check", { "entitle.subject": subject, "entitle.feature": key }, async () => {
230
322
  const limit = await this.limit(subject, key)
231
- const usage = await meter.usage({ subject, metric: key, ...usageQuery })
323
+ const usage = await meter.usage({ ...usageQuery, subject, metric: key })
232
324
  const used = usage.quantity
233
325
  const allowed = limit === null || used < limit
234
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)
@@ -16,17 +16,33 @@ export function createEntitlements(options?: EntitlementsOptions): {
16
16
  */
17
17
  assign(subject: string, plan: string): Promise<void>;
18
18
  /**
19
- * Layer a per-subject override on top of the subject's plan. Shallow-merges,
20
- * so pass only what differs. Takes effect immediately.
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>;
25
+ /**
26
+ * Add or adjust a per-subject override, layered on top of the subject's plan.
27
+ * Merges into any existing override for the subject, so repeated calls
28
+ * accumulate: overriding `seats`, then later overriding `sso`, leaves both in
29
+ * place, and overriding a key again updates just that key. Use clearOverride
30
+ * to remove overrides. Takes effect immediately.
21
31
  * @param {string} subject
22
32
  * @param {Override} data
23
33
  */
24
34
  override(subject: string, data: Override): Promise<void>;
25
35
  /**
26
- * Remove a subject's override, falling back to plain plan entitlements.
36
+ * Remove overrides for a subject. With no `keys`, removes the entire override
37
+ * and the subject falls back to plain plan entitlements. With `keys`, removes
38
+ * only those override entries (reverting them to the plan) and keeps the rest.
27
39
  * @param {string} subject
40
+ * @param {{ features?: string[], limits?: string[] }} [keys]
28
41
  */
29
- clearOverride(subject: string): Promise<void>;
42
+ clearOverride(subject: string, keys?: {
43
+ features?: string[];
44
+ limits?: string[];
45
+ }): Promise<void>;
30
46
  /**
31
47
  * The subject's effective plan name (the default plan if unassigned).
32
48
  * @param {string} subject
@@ -34,15 +50,20 @@ export function createEntitlements(options?: EntitlementsOptions): {
34
50
  */
35
51
  plan(subject: string): Promise<string>;
36
52
  /**
37
- * 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.
38
56
  * @param {string} subject
39
57
  * @param {string} feature
40
58
  * @returns {Promise<boolean>}
41
59
  */
42
60
  can(subject: string, feature: string): Promise<boolean>;
43
61
  /**
44
- * The numeric ceiling for a limit key, after overrides. Returns `null` for an
45
- * 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.
46
67
  * @param {string} subject
47
68
  * @param {string} key
48
69
  * @returns {Promise<number|null>}
@@ -103,6 +124,14 @@ export type EntitlementsOptions = {
103
124
  * - plan applied to a subject with no assignment; must be a key of `plans`
104
125
  */
105
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[];
106
135
  /**
107
136
  * - optional `@prsm/meter` instance; required only for `check()`, which reads live usage from it
108
137
  */