@prsm/entitle 1.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 ADDED
@@ -0,0 +1,183 @@
1
+ <p align="center">
2
+ <img src="logo.svg" width="80" height="80" alt="entitle logo">
3
+ </p>
4
+
5
+ <h1 align="center">@prsm/entitle</h1>
6
+
7
+ <p align="center">
8
+ <a href="https://github.com/prsmjs/entitle/actions/workflows/test.yml"><img src="https://github.com/prsmjs/entitle/actions/workflows/test.yml/badge.svg" alt="test"></a>
9
+ <a href="https://www.npmjs.com/package/@prsm/entitle"><img src="https://img.shields.io/npm/v/@prsm/entitle" alt="npm"></a>
10
+ </p>
11
+
12
+ Plan-based entitlements and feature gating, backed by postgres. Declare your pricing tiers once, assign subjects to plans, and ask at runtime what a subject is allowed to do. Entitlements resolve live on every call, so an upgrade, a negotiated override, or a crossed usage threshold takes effect immediately rather than at the next restart.
13
+
14
+ It pairs with [@prsm/meter](https://www.npmjs.com/package/@prsm/meter): meter answers how much a subject has used, entitle answers what their plan allows and where the ceiling is. Hand entitle a meter and `check()` reads usage live and compares it to the resolved limit.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install @prsm/entitle pg
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ ```js
25
+ import { createEntitlements, postgresDriver } from "@prsm/entitle"
26
+ import pg from "pg"
27
+
28
+ const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
29
+
30
+ const entitlements = createEntitlements({
31
+ driver: postgresDriver({ pool }),
32
+ defaultPlan: "free",
33
+ plans: {
34
+ free: {
35
+ features: { api_access: true, export_csv: false, sso: false },
36
+ limits: { tokens: 100_000, seats: 1 },
37
+ },
38
+ pro: {
39
+ features: { api_access: true, export_csv: true, sso: false },
40
+ limits: { tokens: 5_000_000, seats: 10 },
41
+ },
42
+ enterprise: {
43
+ features: { api_access: true, export_csv: true, sso: true },
44
+ limits: { tokens: null, seats: null }, // null means unlimited
45
+ },
46
+ },
47
+ })
48
+
49
+ await entitlements.setup() // create tables if they do not exist; idempotent
50
+ ```
51
+
52
+ Gating a feature and a quota on a request:
53
+
54
+ ```js
55
+ async function exportReport(account) {
56
+ if (!(await entitlements.can(account.id, "export_csv"))) {
57
+ throw new Error("CSV export is not available on your plan")
58
+ }
59
+ // ...
60
+ }
61
+ ```
62
+
63
+ Assigning plans and per-subject overrides as customers upgrade or negotiate:
64
+
65
+ ```js
66
+ await entitlements.assign(account.id, "pro") // takes effect immediately
67
+ await entitlements.override(account.id, { limits: { seats: 50 } }) // the enterprise customer who negotiated more seats
68
+ await entitlements.override(account.id, { features: { sso: true } })
69
+ ```
70
+
71
+ ## Composing with the meter
72
+
73
+ `check()` is the seam between the two packages. Entitle resolves the limit; meter supplies live usage. Pass a `@prsm/meter` instance and check a limit key that is also a meter metric:
74
+
75
+ ```js
76
+ import { createMeter, postgresDriver as meterPostgres } from "@prsm/meter"
77
+
78
+ const meter = createMeter({
79
+ driver: meterPostgres({ pool }),
80
+ metrics: { tokens: { unit: "tokens", aggregate: "sum" } },
81
+ })
82
+
83
+ const entitlements = createEntitlements({
84
+ driver: postgresDriver({ pool }),
85
+ defaultPlan: "free",
86
+ plans: { /* ... */ },
87
+ meter,
88
+ })
89
+
90
+ const quota = await entitlements.check(account.id, "tokens")
91
+ // { allowed: true, used: 84210, remaining: 4915790, limit: 5000000, unit: "tokens", feature: "tokens" }
92
+
93
+ if (!quota.allowed) throw new Error("monthly token limit reached")
94
+ ```
95
+
96
+ 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
+
98
+ ## Plans, assignments, and overrides
99
+
100
+ The **plan catalog** is declared once at construction, the way you declare your pricing tiers in code. A plan grants:
101
+
102
+ - **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.
104
+
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`.
106
+
107
+ 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
+
109
+ ## API
110
+
111
+ ### `createEntitlements({ driver, plans, defaultPlan, meter?, cacheTtl?, tracer? })`
112
+
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.
114
+
115
+ ### `entitlements.setup()`
116
+
117
+ Creates the backing tables if they do not exist. Idempotent.
118
+
119
+ ### `entitlements.assign(subject, plan)`
120
+
121
+ Assigns a subject to a plan. Takes effect immediately.
122
+
123
+ ### `entitlements.override(subject, { features?, limits? })`
124
+
125
+ Layers a per-subject override on top of the plan. Shallow-merges; pass only what differs.
126
+
127
+ ### `entitlements.clearOverride(subject)`
128
+
129
+ Removes the subject's override.
130
+
131
+ ### `entitlements.can(subject, feature)`
132
+
133
+ Returns whether a capability flag is granted (`false` for an ungranted or unknown feature).
134
+
135
+ ### `entitlements.limit(subject, key)`
136
+
137
+ Returns the numeric ceiling for a limit key after overrides, or `null` for an unlimited or undeclared limit. Does not read usage.
138
+
139
+ ### `entitlements.check(subject, key, usageQuery?)`
140
+
141
+ Resolves the limit and reads live usage from the meter, returning `{ allowed, used, remaining, limit, unit, feature }`. `usageQuery` is forwarded to `meter.usage` (for example `{ period: "day" }`). Requires a `meter`.
142
+
143
+ ### `entitlements.plan(subject)`
144
+
145
+ Returns the subject's effective plan name.
146
+
147
+ ### `entitlements.describe(subject)`
148
+
149
+ Returns the full effective snapshot `{ plan, features, limits }`, for a settings or billing page.
150
+
151
+ ### `entitlements.close()`
152
+
153
+ Releases driver resources.
154
+
155
+ ## Storage
156
+
157
+ Two postgres tables, prefixed `entitle_` by default (pass `prefix` to `postgresDriver` to run several resolvers in one database):
158
+
159
+ - `entitle_assignments` maps a subject to a plan.
160
+ - `entitle_overrides` holds each subject's override as JSON.
161
+
162
+ The plan catalog itself is code, not data, so it is not stored.
163
+
164
+ ## Testing
165
+
166
+ The `memoryDriver` mirrors the postgres driver and needs no infrastructure:
167
+
168
+ ```js
169
+ import { createEntitlements, memoryDriver } from "@prsm/entitle"
170
+
171
+ const entitlements = createEntitlements({
172
+ driver: memoryDriver(),
173
+ defaultPlan: "free",
174
+ plans: { free: { features: { api_access: true }, limits: { seats: 1 } } },
175
+ })
176
+ await entitlements.setup()
177
+ ```
178
+
179
+ It is not durable; use it for tests, not production.
180
+
181
+ ## License
182
+
183
+ MIT
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@prsm/entitle",
3
+ "version": "1.0.0",
4
+ "description": "Plan-based entitlements and feature gating, resolved at runtime, backed by postgres",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./types/index.d.ts",
9
+ "default": "./src/index.js"
10
+ },
11
+ "./postgres": {
12
+ "types": "./types/postgresDriver.d.ts",
13
+ "default": "./src/postgresDriver.js"
14
+ },
15
+ "./memory": {
16
+ "types": "./types/memoryDriver.d.ts",
17
+ "default": "./src/memoryDriver.js"
18
+ }
19
+ },
20
+ "types": "./types/index.d.ts",
21
+ "files": [
22
+ "src",
23
+ "types"
24
+ ],
25
+ "scripts": {
26
+ "test": "vitest --reporter=verbose --run",
27
+ "test:watch": "vitest",
28
+ "prepublishOnly": "npx tsc --declaration --allowJs --emitDeclarationOnly --skipLibCheck --target es2020 --module nodenext --moduleResolution nodenext --strict false --esModuleInterop true --outDir ./types src/index.js src/postgresDriver.js src/memoryDriver.js"
29
+ },
30
+ "keywords": [
31
+ "entitlements",
32
+ "feature-flags",
33
+ "feature-gating",
34
+ "plans",
35
+ "quota",
36
+ "postgres"
37
+ ],
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/prsmjs/entitle.git"
42
+ },
43
+ "homepage": "https://github.com/prsmjs/entitle#readme",
44
+ "bugs": {
45
+ "url": "https://github.com/prsmjs/entitle/issues"
46
+ },
47
+ "dependencies": {
48
+ "@prsm/ms": "^2.0.0"
49
+ },
50
+ "peerDependencies": {
51
+ "pg": "^8.0.0"
52
+ },
53
+ "peerDependenciesMeta": {
54
+ "pg": {
55
+ "optional": true
56
+ }
57
+ },
58
+ "devDependencies": {
59
+ "pg": "^8.16.3",
60
+ "typescript": "^5.9.3",
61
+ "vitest": "^3.2.4"
62
+ },
63
+ "engines": {
64
+ "node": ">=18"
65
+ }
66
+ }
@@ -0,0 +1,260 @@
1
+ import ms from "@prsm/ms"
2
+
3
+ /**
4
+ * @typedef {Object} Plan
5
+ * @property {Record<string, boolean>} [features] - capability flags this plan grants (`{ sso: true, export_csv: false }`)
6
+ * @property {Record<string, number|null>} [limits] - numeric ceilings keyed by metric name; `null` means unlimited (`{ tokens: 1000000, seats: 5 }`)
7
+ */
8
+
9
+ /**
10
+ * @typedef {Object} Override
11
+ * A per-subject override layered on top of the subject's plan. Shallow-merges
12
+ * over the plan, so you only specify what differs (the enterprise customer who
13
+ * negotiated more seats, or got one feature switched on).
14
+ * @property {Record<string, boolean>} [features]
15
+ * @property {Record<string, number|null>} [limits]
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} EntitlementsOptions
20
+ * @property {Object} driver - storage backend: `postgresDriver({ pool })` for production, `memoryDriver()` for tests
21
+ * @property {Record<string, Plan>} plans - the plan catalog, declared once at construction (your pricing tiers)
22
+ * @property {string} defaultPlan - plan applied to a subject with no assignment; must be a key of `plans`
23
+ * @property {{ usage: Function }} [meter] - optional `@prsm/meter` instance; required only for `check()`, which reads live usage from it
24
+ * @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
+ * @property {{ startSpan: Function }} [tracer] - optional `@prsm/trace` tracer
26
+ */
27
+
28
+ /**
29
+ * @typedef {Object} Effective
30
+ * @property {string} plan - the effective plan name
31
+ * @property {Record<string, boolean>} features
32
+ * @property {Record<string, number|null>} limits
33
+ */
34
+
35
+ /**
36
+ * @typedef {Object} CheckResult
37
+ * @property {boolean} allowed - whether current usage is below the limit (always true when the limit is unlimited)
38
+ * @property {number} used - current usage, read live from the meter
39
+ * @property {number|null} remaining - `max(0, limit - used)`, or `null` when unlimited
40
+ * @property {number|null} limit - the resolved ceiling, or `null` when unlimited
41
+ * @property {string} [unit] - the meter unit for this metric
42
+ * @property {string} feature - the limit key that was checked
43
+ */
44
+
45
+ function validatePlans(plans, defaultPlan) {
46
+ if (!plans || typeof plans !== "object" || Object.keys(plans).length === 0) {
47
+ throw new Error("createEntitlements requires a non-empty `plans` catalog")
48
+ }
49
+ for (const [name, plan] of Object.entries(plans)) {
50
+ if (plan.features && typeof plan.features !== "object") throw new Error(`plan "${name}" features must be an object`)
51
+ if (plan.limits && typeof plan.limits !== "object") throw new Error(`plan "${name}" limits must be an object`)
52
+ }
53
+ if (!defaultPlan || !plans[defaultPlan]) {
54
+ throw new Error(`createEntitlements requires \`defaultPlan\` to be one of the declared plans: ${Object.keys(plans).join(", ")}`)
55
+ }
56
+ }
57
+
58
+ function validateOverride(data) {
59
+ if (!data || typeof data !== "object") throw new Error("override data must be an object with `features` and/or `limits`")
60
+ for (const [k, v] of Object.entries(data.features ?? {})) {
61
+ if (typeof v !== "boolean") throw new Error(`override feature "${k}" must be a boolean`)
62
+ }
63
+ 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`)
65
+ }
66
+ }
67
+
68
+ function createCache(ttl) {
69
+ const store = new Map()
70
+ return {
71
+ get(subject) {
72
+ if (ttl <= 0) return undefined
73
+ const hit = store.get(subject)
74
+ if (!hit) return undefined
75
+ if (hit.expires <= Date.now()) {
76
+ store.delete(subject)
77
+ return undefined
78
+ }
79
+ return hit.value
80
+ },
81
+ set(subject, value) {
82
+ if (ttl <= 0) return
83
+ store.set(subject, { value, expires: Date.now() + ttl })
84
+ },
85
+ invalidate(subject) {
86
+ store.delete(subject)
87
+ },
88
+ }
89
+ }
90
+
91
+ async function traced(tracer, name, attrs, fn) {
92
+ const span = tracer?.startSpan(name, attrs)
93
+ try {
94
+ return await fn()
95
+ } catch (err) {
96
+ span?.setError(err)
97
+ throw err
98
+ } finally {
99
+ span?.end()
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Create an entitlements resolver: given a subject, decide what their plan
105
+ * allows right now. Plans are declared up front; assignments and overrides live
106
+ * in the driver and are resolved live on every call, so a plan change or a
107
+ * crossed usage threshold takes effect immediately, not at the next restart.
108
+ *
109
+ * @param {EntitlementsOptions} options
110
+ */
111
+ export function createEntitlements(options = {}) {
112
+ const { driver, plans, defaultPlan, meter = null, tracer = null } = options
113
+ if (!driver) throw new Error("createEntitlements requires a `driver` (postgresDriver or memoryDriver)")
114
+ validatePlans(plans, defaultPlan)
115
+
116
+ const catalog = { ...plans }
117
+ const cache = createCache(ms(options.cacheTtl ?? "10s"))
118
+
119
+ async function resolve(subject) {
120
+ if (!subject) throw new Error("a `subject` is required")
121
+ const cached = cache.get(subject)
122
+ if (cached) return cached
123
+
124
+ const state = await driver.getState(subject)
125
+ const planName = state?.plan ?? defaultPlan
126
+ const planDef = catalog[planName]
127
+ if (!planDef) {
128
+ throw new Error(`subject "${subject}" is assigned to unknown plan "${planName}"`)
129
+ }
130
+ const override = state?.override ?? {}
131
+ const effective = {
132
+ plan: planName,
133
+ features: { ...(planDef.features ?? {}), ...(override.features ?? {}) },
134
+ limits: { ...(planDef.limits ?? {}), ...(override.limits ?? {}) },
135
+ }
136
+ cache.set(subject, effective)
137
+ return effective
138
+ }
139
+
140
+ return {
141
+ /** Create the backing tables if they do not exist. Idempotent. */
142
+ setup() {
143
+ return driver.setup()
144
+ },
145
+
146
+ /**
147
+ * Assign a subject to a plan. Takes effect immediately.
148
+ * @param {string} subject
149
+ * @param {string} plan - a key of the plan catalog
150
+ */
151
+ async assign(subject, plan) {
152
+ if (!subject) throw new Error("assign requires a `subject`")
153
+ if (!catalog[plan]) throw new Error(`unknown plan "${plan}". declared plans: ${Object.keys(catalog).join(", ")}`)
154
+ await driver.assign(subject, plan)
155
+ cache.invalidate(subject)
156
+ },
157
+
158
+ /**
159
+ * Layer a per-subject override on top of the subject's plan. Shallow-merges,
160
+ * so pass only what differs. Takes effect immediately.
161
+ * @param {string} subject
162
+ * @param {Override} data
163
+ */
164
+ async override(subject, data) {
165
+ if (!subject) throw new Error("override requires a `subject`")
166
+ validateOverride(data)
167
+ await driver.setOverride(subject, { features: data.features ?? {}, limits: data.limits ?? {} })
168
+ cache.invalidate(subject)
169
+ },
170
+
171
+ /**
172
+ * Remove a subject's override, falling back to plain plan entitlements.
173
+ * @param {string} subject
174
+ */
175
+ async clearOverride(subject) {
176
+ if (!subject) throw new Error("clearOverride requires a `subject`")
177
+ await driver.clearOverride(subject)
178
+ cache.invalidate(subject)
179
+ },
180
+
181
+ /**
182
+ * The subject's effective plan name (the default plan if unassigned).
183
+ * @param {string} subject
184
+ * @returns {Promise<string>}
185
+ */
186
+ async plan(subject) {
187
+ return (await resolve(subject)).plan
188
+ },
189
+
190
+ /**
191
+ * Whether a capability flag is granted to the subject.
192
+ * @param {string} subject
193
+ * @param {string} feature
194
+ * @returns {Promise<boolean>}
195
+ */
196
+ async can(subject, feature) {
197
+ return traced(tracer, "entitle.can", { "entitle.subject": subject, "entitle.feature": feature }, async () => {
198
+ const eff = await resolve(subject)
199
+ return eff.features[feature] === true
200
+ })
201
+ },
202
+
203
+ /**
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.
206
+ * @param {string} subject
207
+ * @param {string} key
208
+ * @returns {Promise<number|null>}
209
+ */
210
+ async limit(subject, key) {
211
+ const eff = await resolve(subject)
212
+ return key in eff.limits ? eff.limits[key] : null
213
+ },
214
+
215
+ /**
216
+ * Check live usage against the subject's limit. Resolves the ceiling, then
217
+ * reads current usage from the meter for the metric of the same name. This is
218
+ * the composition seam: entitle supplies the limit, meter supplies the usage.
219
+ * Requires a `meter` to have been passed to `createEntitlements`.
220
+ * @param {string} subject
221
+ * @param {string} key - a limit key that is also a meter metric
222
+ * @param {{ period?: any, range?: any }} [usageQuery] - forwarded to `meter.usage`
223
+ * @returns {Promise<CheckResult>}
224
+ */
225
+ async check(subject, key, usageQuery = {}) {
226
+ if (!meter) {
227
+ throw new Error("check requires a `meter`; pass it to createEntitlements, or use limit() for the static ceiling")
228
+ }
229
+ return traced(tracer, "entitle.check", { "entitle.subject": subject, "entitle.feature": key }, async () => {
230
+ const limit = await this.limit(subject, key)
231
+ const usage = await meter.usage({ subject, metric: key, ...usageQuery })
232
+ const used = usage.quantity
233
+ const allowed = limit === null || used < limit
234
+ return {
235
+ allowed,
236
+ used,
237
+ remaining: limit === null ? null : Math.max(0, limit - used),
238
+ limit,
239
+ unit: usage.unit,
240
+ feature: key,
241
+ }
242
+ })
243
+ },
244
+
245
+ /**
246
+ * The subject's full effective entitlements, for a settings or billing page.
247
+ * @param {string} subject
248
+ * @returns {Promise<Effective>}
249
+ */
250
+ async describe(subject) {
251
+ const eff = await resolve(subject)
252
+ return { plan: eff.plan, features: { ...eff.features }, limits: { ...eff.limits } }
253
+ },
254
+
255
+ /** Release backing resources (driver connections). */
256
+ close() {
257
+ return driver.close?.()
258
+ },
259
+ }
260
+ }
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { createEntitlements } from "./entitlements.js"
2
+ export { postgresDriver } from "./postgresDriver.js"
3
+ export { memoryDriver } from "./memoryDriver.js"
@@ -0,0 +1,36 @@
1
+ /**
2
+ * In-memory entitlements driver. Mirrors the postgres driver's behavior so the
3
+ * test suite runs without infrastructure. Not durable - state lives for the
4
+ * lifetime of the process.
5
+ *
6
+ * @returns {object} a driver for `createEntitlements({ driver })`
7
+ */
8
+ export function memoryDriver() {
9
+ const assignments = new Map()
10
+ const overrides = new Map()
11
+
12
+ return {
13
+ async setup() {},
14
+
15
+ async getState(subject) {
16
+ return {
17
+ plan: assignments.get(subject) ?? null,
18
+ override: overrides.get(subject) ?? null,
19
+ }
20
+ },
21
+
22
+ async assign(subject, plan) {
23
+ assignments.set(subject, plan)
24
+ },
25
+
26
+ async setOverride(subject, data) {
27
+ overrides.set(subject, data)
28
+ },
29
+
30
+ async clearOverride(subject) {
31
+ overrides.delete(subject)
32
+ },
33
+
34
+ async close() {},
35
+ }
36
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @typedef {Object} PostgresDriverOptions
3
+ * @property {import("pg").Pool} pool - a `pg` Pool
4
+ * @property {string} [prefix] - table name prefix (default `"entitle"`), for keeping several resolvers in one database
5
+ */
6
+
7
+ /**
8
+ * Durable postgres entitlements driver. Two tables: plan assignments and
9
+ * per-subject overrides. Both are keyed by subject and read live on resolution.
10
+ *
11
+ * @param {PostgresDriverOptions} options
12
+ * @returns {object} a driver for `createEntitlements({ driver })`
13
+ */
14
+ export function postgresDriver(options = {}) {
15
+ const { pool, prefix = "entitle" } = options
16
+ if (!pool) throw new Error("postgresDriver requires a `pool`")
17
+
18
+ const assignments = `${prefix}_assignments`
19
+ const overrides = `${prefix}_overrides`
20
+
21
+ return {
22
+ async setup() {
23
+ await pool.query(`
24
+ create table if not exists ${assignments} (
25
+ subject text primary key,
26
+ plan text not null,
27
+ updated_at timestamptz not null default now()
28
+ );
29
+ create table if not exists ${overrides} (
30
+ subject text primary key,
31
+ data jsonb not null,
32
+ updated_at timestamptz not null default now()
33
+ );
34
+ `)
35
+ },
36
+
37
+ async getState(subject) {
38
+ const r = await pool.query(
39
+ `select
40
+ (select plan from ${assignments} where subject = $1) as plan,
41
+ (select data from ${overrides} where subject = $1) as override`,
42
+ [subject],
43
+ )
44
+ return { plan: r.rows[0].plan ?? null, override: r.rows[0].override ?? null }
45
+ },
46
+
47
+ async assign(subject, plan) {
48
+ await pool.query(
49
+ `insert into ${assignments} (subject, plan) values ($1, $2)
50
+ on conflict (subject) do update set plan = excluded.plan, updated_at = now()`,
51
+ [subject, plan],
52
+ )
53
+ },
54
+
55
+ async setOverride(subject, data) {
56
+ 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)],
60
+ )
61
+ },
62
+
63
+ async clearOverride(subject) {
64
+ await pool.query(`delete from ${overrides} where subject = $1`, [subject])
65
+ },
66
+
67
+ async close() {},
68
+ }
69
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Create an entitlements resolver: given a subject, decide what their plan
3
+ * allows right now. Plans are declared up front; assignments and overrides live
4
+ * in the driver and are resolved live on every call, so a plan change or a
5
+ * crossed usage threshold takes effect immediately, not at the next restart.
6
+ *
7
+ * @param {EntitlementsOptions} options
8
+ */
9
+ export function createEntitlements(options?: EntitlementsOptions): {
10
+ /** Create the backing tables if they do not exist. Idempotent. */
11
+ setup(): any;
12
+ /**
13
+ * Assign a subject to a plan. Takes effect immediately.
14
+ * @param {string} subject
15
+ * @param {string} plan - a key of the plan catalog
16
+ */
17
+ assign(subject: string, plan: string): Promise<void>;
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.
21
+ * @param {string} subject
22
+ * @param {Override} data
23
+ */
24
+ override(subject: string, data: Override): Promise<void>;
25
+ /**
26
+ * Remove a subject's override, falling back to plain plan entitlements.
27
+ * @param {string} subject
28
+ */
29
+ clearOverride(subject: string): Promise<void>;
30
+ /**
31
+ * The subject's effective plan name (the default plan if unassigned).
32
+ * @param {string} subject
33
+ * @returns {Promise<string>}
34
+ */
35
+ plan(subject: string): Promise<string>;
36
+ /**
37
+ * Whether a capability flag is granted to the subject.
38
+ * @param {string} subject
39
+ * @param {string} feature
40
+ * @returns {Promise<boolean>}
41
+ */
42
+ can(subject: string, feature: string): Promise<boolean>;
43
+ /**
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.
46
+ * @param {string} subject
47
+ * @param {string} key
48
+ * @returns {Promise<number|null>}
49
+ */
50
+ limit(subject: string, key: string): Promise<number | null>;
51
+ /**
52
+ * Check live usage against the subject's limit. Resolves the ceiling, then
53
+ * reads current usage from the meter for the metric of the same name. This is
54
+ * the composition seam: entitle supplies the limit, meter supplies the usage.
55
+ * Requires a `meter` to have been passed to `createEntitlements`.
56
+ * @param {string} subject
57
+ * @param {string} key - a limit key that is also a meter metric
58
+ * @param {{ period?: any, range?: any }} [usageQuery] - forwarded to `meter.usage`
59
+ * @returns {Promise<CheckResult>}
60
+ */
61
+ check(subject: string, key: string, usageQuery?: {
62
+ period?: any;
63
+ range?: any;
64
+ }): Promise<CheckResult>;
65
+ /**
66
+ * The subject's full effective entitlements, for a settings or billing page.
67
+ * @param {string} subject
68
+ * @returns {Promise<Effective>}
69
+ */
70
+ describe(subject: string): Promise<Effective>;
71
+ /** Release backing resources (driver connections). */
72
+ close(): any;
73
+ };
74
+ export type Plan = {
75
+ /**
76
+ * - capability flags this plan grants (`{ sso: true, export_csv: false }`)
77
+ */
78
+ features?: Record<string, boolean>;
79
+ /**
80
+ * - numeric ceilings keyed by metric name; `null` means unlimited (`{ tokens: 1000000, seats: 5 }`)
81
+ */
82
+ limits?: Record<string, number | null>;
83
+ };
84
+ /**
85
+ * A per-subject override layered on top of the subject's plan. Shallow-merges
86
+ * over the plan, so you only specify what differs (the enterprise customer who
87
+ * negotiated more seats, or got one feature switched on).
88
+ */
89
+ export type Override = {
90
+ features?: Record<string, boolean>;
91
+ limits?: Record<string, number | null>;
92
+ };
93
+ export type EntitlementsOptions = {
94
+ /**
95
+ * - storage backend: `postgresDriver({ pool })` for production, `memoryDriver()` for tests
96
+ */
97
+ driver: any;
98
+ /**
99
+ * - the plan catalog, declared once at construction (your pricing tiers)
100
+ */
101
+ plans: Record<string, Plan>;
102
+ /**
103
+ * - plan applied to a subject with no assignment; must be a key of `plans`
104
+ */
105
+ defaultPlan: string;
106
+ /**
107
+ * - optional `@prsm/meter` instance; required only for `check()`, which reads live usage from it
108
+ */
109
+ meter?: {
110
+ usage: Function;
111
+ };
112
+ /**
113
+ * - 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
114
+ */
115
+ cacheTtl?: number | string;
116
+ /**
117
+ * - optional `@prsm/trace` tracer
118
+ */
119
+ tracer?: {
120
+ startSpan: Function;
121
+ };
122
+ };
123
+ export type Effective = {
124
+ /**
125
+ * - the effective plan name
126
+ */
127
+ plan: string;
128
+ features: Record<string, boolean>;
129
+ limits: Record<string, number | null>;
130
+ };
131
+ export type CheckResult = {
132
+ /**
133
+ * - whether current usage is below the limit (always true when the limit is unlimited)
134
+ */
135
+ allowed: boolean;
136
+ /**
137
+ * - current usage, read live from the meter
138
+ */
139
+ used: number;
140
+ /**
141
+ * - `max(0, limit - used)`, or `null` when unlimited
142
+ */
143
+ remaining: number | null;
144
+ /**
145
+ * - the resolved ceiling, or `null` when unlimited
146
+ */
147
+ limit: number | null;
148
+ /**
149
+ * - the meter unit for this metric
150
+ */
151
+ unit?: string;
152
+ /**
153
+ * - the limit key that was checked
154
+ */
155
+ feature: string;
156
+ };
@@ -0,0 +1,3 @@
1
+ export { createEntitlements } from "./entitlements.js";
2
+ export { postgresDriver } from "./postgresDriver.js";
3
+ export { memoryDriver } from "./memoryDriver.js";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * In-memory entitlements driver. Mirrors the postgres driver's behavior so the
3
+ * test suite runs without infrastructure. Not durable - state lives for the
4
+ * lifetime of the process.
5
+ *
6
+ * @returns {object} a driver for `createEntitlements({ driver })`
7
+ */
8
+ export function memoryDriver(): object;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @typedef {Object} PostgresDriverOptions
3
+ * @property {import("pg").Pool} pool - a `pg` Pool
4
+ * @property {string} [prefix] - table name prefix (default `"entitle"`), for keeping several resolvers in one database
5
+ */
6
+ /**
7
+ * Durable postgres entitlements driver. Two tables: plan assignments and
8
+ * per-subject overrides. Both are keyed by subject and read live on resolution.
9
+ *
10
+ * @param {PostgresDriverOptions} options
11
+ * @returns {object} a driver for `createEntitlements({ driver })`
12
+ */
13
+ export function postgresDriver(options?: PostgresDriverOptions): object;
14
+ export type PostgresDriverOptions = {
15
+ /**
16
+ * - a `pg` Pool
17
+ */
18
+ pool: any;
19
+ /**
20
+ * - table name prefix (default `"entitle"`), for keeping several resolvers in one database
21
+ */
22
+ prefix?: string;
23
+ };