@prsm/entitle 1.1.0 → 2.1.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
@@ -87,6 +91,7 @@ const entitlements = createEntitlements({
87
91
  meter,
88
92
  })
89
93
 
94
+ // account.id is assigned the "pro" plan, whose tokens limit is 5_000_000
90
95
  const quota = await entitlements.check(account.id, "tokens")
91
96
  // { allowed: true, used: 84210, remaining: 4915790, limit: 5000000, unit: "tokens", feature: "tokens" }
92
97
 
@@ -95,16 +100,25 @@ if (!quota.allowed) throw new Error("monthly token limit reached")
95
100
 
96
101
  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
102
 
103
+ ### Who owns what
104
+
105
+ 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.
106
+
107
+ Two things must line up for `check()` to work:
108
+
109
+ - **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.
110
+ - **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.
111
+
98
112
  ## Plans, assignments, and overrides
99
113
 
100
114
  The **plan catalog** is declared once at construction, the way you declare your pricing tiers in code. A plan grants:
101
115
 
102
116
  - **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.
117
+ - **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
118
 
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`.
119
+ **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
120
 
107
- 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
+ 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. The merge happens in the database under a row lock, so two concurrent overrides to the same subject both land instead of one clobbering the other. `clearOverride(subject)` removes the whole override; `clearOverride(subject, { limits: ["seats"] })` reverts only the named keys and keeps the rest.
108
122
 
109
123
  ```js
110
124
  await entitlements.override(account.id, { limits: { seats: 50 } })
@@ -117,9 +131,9 @@ Resolved entitlements are cached per subject for a short, configurable window (`
117
131
 
118
132
  ## API
119
133
 
120
- ### `createEntitlements({ driver, plans, defaultPlan, meter?, cacheTtl?, tracer? })`
134
+ ### `createEntitlements({ driver, plans, defaultPlan, features?, limits?, meter?, cacheTtl?, tracer? })`
121
135
 
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.
136
+ 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; `can()` and `check()` are wrapped in spans when it is present.
123
137
 
124
138
  ### `entitlements.setup()`
125
139
 
@@ -129,6 +143,10 @@ Creates the backing tables if they do not exist. Idempotent.
129
143
 
130
144
  Assigns a subject to a plan. Takes effect immediately.
131
145
 
146
+ ### `entitlements.unassign(subject)`
147
+
148
+ Removes a subject's assignment, reverting them to the default plan.
149
+
132
150
  ### `entitlements.override(subject, { features?, limits? })`
133
151
 
134
152
  Layers a per-subject override on top of the plan. Shallow-merges; pass only what differs.
@@ -139,11 +157,11 @@ With no `keys`, removes the subject's entire override. With `keys` (`{ features?
139
157
 
140
158
  ### `entitlements.can(subject, feature)`
141
159
 
142
- Returns whether a capability flag is granted (`false` for an ungranted or unknown feature).
160
+ 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
161
 
144
162
  ### `entitlements.limit(subject, key)`
145
163
 
146
- Returns the numeric ceiling for a limit key after overrides, or `null` for an unlimited or undeclared limit. Does not read usage.
164
+ 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
165
 
148
166
  ### `entitlements.check(subject, key, usageQuery?)`
149
167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prsm/entitle",
3
- "version": "1.1.0",
3
+ "version": "2.1.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,12 +225,8 @@ 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)
170
- const current = (await driver.getState(subject))?.override ?? {}
171
- await driver.setOverride(subject, {
172
- features: { ...(current.features ?? {}), ...(data.features ?? {}) },
173
- limits: { ...(current.limits ?? {}), ...(data.limits ?? {}) },
174
- })
228
+ validateOverride(data, featureUniverse, limitUniverse)
229
+ await driver.mergeOverride(subject, { features: data.features ?? {}, limits: data.limits ?? {} })
175
230
  cache.invalidate(subject)
176
231
  },
177
232
 
@@ -186,20 +241,8 @@ export function createEntitlements(options = {}) {
186
241
  if (!subject) throw new Error("clearOverride requires a `subject`")
187
242
  if (!keys) {
188
243
  await driver.clearOverride(subject)
189
- cache.invalidate(subject)
190
- return
191
- }
192
- const current = (await driver.getState(subject))?.override
193
- if (current) {
194
- const features = { ...(current.features ?? {}) }
195
- const limits = { ...(current.limits ?? {}) }
196
- for (const k of keys.features ?? []) delete features[k]
197
- for (const k of keys.limits ?? []) delete limits[k]
198
- if (Object.keys(features).length === 0 && Object.keys(limits).length === 0) {
199
- await driver.clearOverride(subject)
200
- } else {
201
- await driver.setOverride(subject, { features, limits })
202
- }
244
+ } else {
245
+ await driver.removeOverrideKeys(subject, { features: keys.features ?? [], limits: keys.limits ?? [] })
203
246
  }
204
247
  cache.invalidate(subject)
205
248
  },
@@ -214,12 +257,15 @@ export function createEntitlements(options = {}) {
214
257
  },
215
258
 
216
259
  /**
217
- * Whether a capability flag is granted to the subject.
260
+ * Whether a capability flag is granted to the subject. Throws on a feature
261
+ * key outside the declared/derived universe, so typos surface instead of
262
+ * silently returning false.
218
263
  * @param {string} subject
219
264
  * @param {string} feature
220
265
  * @returns {Promise<boolean>}
221
266
  */
222
267
  async can(subject, feature) {
268
+ requireFeature(feature)
223
269
  return traced(tracer, "entitle.can", { "entitle.subject": subject, "entitle.feature": feature }, async () => {
224
270
  const eff = await resolve(subject)
225
271
  return eff.features[feature] === true
@@ -227,15 +273,19 @@ export function createEntitlements(options = {}) {
227
273
  },
228
274
 
229
275
  /**
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.
276
+ * The numeric ceiling for a limit key, after overrides. Returns `null` only
277
+ * when the limit is explicitly unlimited, and `0` for a known limit the
278
+ * subject's plan does not grant - never silently unlimited. Throws on a key
279
+ * outside the declared/derived universe. This is the static ceiling; it does
280
+ * not read usage.
232
281
  * @param {string} subject
233
282
  * @param {string} key
234
283
  * @returns {Promise<number|null>}
235
284
  */
236
285
  async limit(subject, key) {
286
+ requireLimit(key)
237
287
  const eff = await resolve(subject)
238
- return key in eff.limits ? eff.limits[key] : null
288
+ return key in eff.limits ? eff.limits[key] : 0
239
289
  },
240
290
 
241
291
  /**
@@ -254,7 +304,7 @@ export function createEntitlements(options = {}) {
254
304
  }
255
305
  return traced(tracer, "entitle.check", { "entitle.subject": subject, "entitle.feature": key }, async () => {
256
306
  const limit = await this.limit(subject, key)
257
- const usage = await meter.usage({ subject, metric: key, ...usageQuery })
307
+ const usage = await meter.usage({ ...usageQuery, subject, metric: key })
258
308
  const used = usage.quantity
259
309
  const allowed = limit === null || used < limit
260
310
  return {
@@ -23,8 +23,26 @@ export function memoryDriver() {
23
23
  assignments.set(subject, plan)
24
24
  },
25
25
 
26
- async setOverride(subject, data) {
27
- overrides.set(subject, data)
26
+ async unassign(subject) {
27
+ assignments.delete(subject)
28
+ },
29
+
30
+ async mergeOverride(subject, delta) {
31
+ const current = overrides.get(subject) ?? { features: {}, limits: {} }
32
+ overrides.set(subject, {
33
+ features: { ...(current.features ?? {}), ...(delta.features ?? {}) },
34
+ limits: { ...(current.limits ?? {}), ...(delta.limits ?? {}) },
35
+ })
36
+ },
37
+
38
+ async removeOverrideKeys(subject, keys) {
39
+ const current = overrides.get(subject)
40
+ if (!current) return
41
+ const features = { ...(current.features ?? {}) }
42
+ const limits = { ...(current.limits ?? {}) }
43
+ for (const k of keys.features ?? []) delete features[k]
44
+ for (const k of keys.limits ?? []) delete limits[k]
45
+ overrides.set(subject, { features, limits })
28
46
  },
29
47
 
30
48
  async clearOverride(subject) {
@@ -52,11 +52,34 @@ export function postgresDriver(options = {}) {
52
52
  )
53
53
  },
54
54
 
55
- async setOverride(subject, data) {
55
+ async unassign(subject) {
56
+ await pool.query(`delete from ${assignments} where subject = $1`, [subject])
57
+ },
58
+
59
+ async mergeOverride(subject, delta) {
60
+ await pool.query(
61
+ `insert into ${overrides} (subject, data)
62
+ values ($1, jsonb_build_object('features', $2::jsonb, 'limits', $3::jsonb))
63
+ on conflict (subject) do update set
64
+ data = jsonb_build_object(
65
+ 'features', coalesce(${overrides}.data -> 'features', '{}'::jsonb) || (excluded.data -> 'features'),
66
+ 'limits', coalesce(${overrides}.data -> 'limits', '{}'::jsonb) || (excluded.data -> 'limits')
67
+ ),
68
+ updated_at = now()`,
69
+ [subject, JSON.stringify(delta.features ?? {}), JSON.stringify(delta.limits ?? {})],
70
+ )
71
+ },
72
+
73
+ async removeOverrideKeys(subject, keys) {
56
74
  await pool.query(
57
- `insert into ${overrides} (subject, data) values ($1, $2)
58
- on conflict (subject) do update set data = excluded.data, updated_at = now()`,
59
- [subject, JSON.stringify(data)],
75
+ `update ${overrides} set
76
+ data = jsonb_build_object(
77
+ 'features', coalesce(data -> 'features', '{}'::jsonb) - $2::text[],
78
+ 'limits', coalesce(data -> 'limits', '{}'::jsonb) - $3::text[]
79
+ ),
80
+ updated_at = now()
81
+ where subject = $1`,
82
+ [subject, keys.features ?? [], keys.limits ?? []],
60
83
  )
61
84
  },
62
85
 
@@ -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
  */