@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 +23 -6
- package/package.json +1 -1
- package/src/entitlements.js +80 -14
- package/src/memoryDriver.js +4 -0
- package/src/postgresDriver.js +4 -0
- package/types/entitlements.d.ts +23 -3
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
|
|
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
|
|
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
package/src/entitlements.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
231
|
-
*
|
|
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] :
|
|
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
|
|
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 {
|
package/src/memoryDriver.js
CHANGED
package/src/postgresDriver.js
CHANGED
|
@@ -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)
|
package/types/entitlements.d.ts
CHANGED
|
@@ -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`
|
|
54
|
-
*
|
|
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
|
*/
|