@molecule/api-entitlements 1.0.0 → 1.0.1

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.
Files changed (2) hide show
  1. package/README.md +709 -0
  2. package/package.json +14 -13
package/README.md ADDED
@@ -0,0 +1,709 @@
1
+ <!--
2
+ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
+ Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
+ Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
+ To change this document, edit the module-level JSDoc in src/index.ts.
6
+ Generated: 2026-08-04T01:48:07.034Z
7
+ -->
8
+
9
+ # @molecule/api-entitlements
10
+
11
+ > **Auto-generated, AI-first package reference** for the [molecule.dev](https://molecule.dev) ecosystem.
12
+ > It is written to be read by coding agents as much as by people, and is generated from this
13
+ > package's source — edit `src/index.ts` JSDoc, not this file.
14
+
15
+ Tier-based entitlements core for molecule.dev.
16
+
17
+ Provides the typed `Tier<TLimits>` / `TierRegistry<TLimits>` shapes, a
18
+ per-process plan-key cache, and Express middleware factories that gate
19
+ endpoints by tier category or quantitative limit.
20
+
21
+ Apps declare their own `TLimits` shape, construct a registry via
22
+ `defineTiers(...)`, and bond it via `setProvider(...)` at startup. The
23
+ webhook glue that maps Stripe / Apple / Google subscription events to
24
+ `users.planKey` already lives in `@molecule/api-resource-user`.
25
+
26
+ ## Quick Start
27
+
28
+ ```typescript
29
+ import {
30
+ defineTiers,
31
+ setProvider,
32
+ enforceLimit,
33
+ requireCategoryAtLeast,
34
+ } from '@molecule/api-entitlements'
35
+ import { count } from '@molecule/api-database'
36
+
37
+ interface BlogLimits {
38
+ maxPosts: number
39
+ maxCommentsPerDay: number
40
+ }
41
+
42
+ const registry = defineTiers<BlogLimits>({
43
+ tiers: {
44
+ free: {
45
+ planKey: 'free',
46
+ category: 'free',
47
+ name: 'Free',
48
+ limits: { maxPosts: 5, maxCommentsPerDay: 50 },
49
+ },
50
+ stripeMonthly: {
51
+ planKey: 'stripeMonthly',
52
+ category: 'pro',
53
+ name: 'Pro',
54
+ limits: { maxPosts: 100, maxCommentsPerDay: 1000 },
55
+ },
56
+ },
57
+ defaultPlanKey: 'free',
58
+ categoryOrder: ['free', 'pro'],
59
+ })
60
+
61
+ setProvider(registry)
62
+
63
+ // Gate the API routes — the SERVER enforces tiers, never the UI alone:
64
+ router.post(
65
+ '/posts',
66
+ enforceLimit<BlogLimits>({
67
+ limitType: 'maxPosts',
68
+ getLimit: (limits) => limits.maxPosts,
69
+ getCurrent: (userId) => count('posts', [{ field: 'userId', operator: '=', value: userId }]),
70
+ }),
71
+ handlers.createPost,
72
+ )
73
+ router.get('/analytics', requireCategoryAtLeast('pro'), handlers.analytics)
74
+ ```
75
+
76
+ ## Type
77
+
78
+ `core`
79
+
80
+ ## Installation
81
+
82
+ ```bash
83
+ npm install @molecule/api-entitlements @molecule/api-bond @molecule/api-database @molecule/api-i18n @molecule/api-rate-limit
84
+ ```
85
+
86
+ ## API
87
+
88
+ ### Interfaces
89
+
90
+ #### `BuildLimitErrorOptions`
91
+
92
+ Options for building a limit error payload.
93
+
94
+ ```typescript
95
+ interface BuildLimitErrorOptions<TLimits = unknown> {
96
+ /** Identifier for the limit that was hit (e.g. `'maxProjects'`). */
97
+ limitType: LimitType
98
+
99
+ /** The current user's tier category (e.g. `'free'`, `'anonymous'`). */
100
+ category: string
101
+
102
+ /** The numeric limit that was exceeded. */
103
+ currentLimit: number
104
+
105
+ /**
106
+ * Optional accessor that maps the next-up tier's `limits` to the relevant
107
+ * numeric value. When omitted, `upgradedLimit` is `null` and the upgrade
108
+ * prompt simply names the next tier without a number.
109
+ */
110
+ resolveUpgradedLimit?: (nextLimits: TLimits) => number | null | undefined
111
+
112
+ /** Seconds until the client should retry, when applicable. */
113
+ retryAfter?: number
114
+
115
+ /** Optional override for the localized error message. */
116
+ message?: string
117
+ }
118
+ ```
119
+
120
+ #### `DefineTiersOptions`
121
+
122
+ Options for constructing a `TierRegistry` via `defineTiers`.
123
+
124
+ ```typescript
125
+ interface DefineTiersOptions<TLimits = unknown> {
126
+ /** All tiers indexed by `planKey`. Must include an entry matching `defaultPlanKey`. */
127
+ tiers: Record<string, Tier<TLimits>>
128
+
129
+ /**
130
+ * The plan key that maps to the default tier — used for unrecognized,
131
+ * expired, or null plan keys. Conventionally `'free'` or `''`.
132
+ */
133
+ defaultPlanKey: string
134
+
135
+ /**
136
+ * The category upgrade order. Categories listed earlier are considered
137
+ * lower-tier; later ones higher. Used by `getNextCategory` to power
138
+ * upgrade prompts.
139
+ *
140
+ * @example `['anonymous', 'free', 'pro', 'team']`
141
+ */
142
+ categoryOrder: string[]
143
+ }
144
+ ```
145
+
146
+ #### `EnforceLimitOptions`
147
+
148
+ Options for the `enforceLimit` middleware.
149
+
150
+ ```typescript
151
+ interface EnforceLimitOptions<TLimits = unknown> {
152
+ /** Stable identifier for the limit (used in error payloads, telemetry). */
153
+ limitType: LimitType
154
+
155
+ /**
156
+ * Pulls the numeric cap out of the user's tier `limits` object.
157
+ *
158
+ * @param limits - The tier-specific limits.
159
+ * @returns The numeric cap to enforce.
160
+ */
161
+ getLimit: (limits: TLimits) => number
162
+
163
+ /**
164
+ * Computes the user's current usage. Receives the userId resolved from the
165
+ * session and the request object so apps can scope by additional fields
166
+ * (e.g. organization, project) when needed.
167
+ *
168
+ * @param userId - The authenticated user ID.
169
+ * @param req - The incoming request, in case scoping needs query/body data.
170
+ * @returns The current usage count.
171
+ */
172
+ getCurrent: (userId: string, req: Request) => Promise<number> | number
173
+
174
+ /**
175
+ * Optional override for the response status. Defaults to 403; some apps
176
+ * prefer 429 for usage-style limits.
177
+ */
178
+ status?: number
179
+ }
180
+ ```
181
+
182
+ #### `LimitErrorPayload`
183
+
184
+ Structured payload returned to clients when a tier limit is exceeded.
185
+
186
+ Frontends use this to render upgrade prompts that name the user's current
187
+ tier, the limit that was hit, and the next tier that would lift it.
188
+
189
+ ```typescript
190
+ interface LimitErrorPayload {
191
+ /** Localized human-readable error message. */
192
+ error: string
193
+
194
+ /** Stable machine-readable identifier of the limit type (e.g. `'maxProjects'`). */
195
+ limitType: LimitType
196
+
197
+ /** The numeric limit on the user's current tier. */
198
+ currentLimit: number
199
+
200
+ /** The numeric limit the user would have on the next-up tier, or `null` if none. */
201
+ upgradedLimit: number | null
202
+
203
+ /** The user's current tier category. */
204
+ currentTier: string
205
+
206
+ /** The next-up tier category, or `null` if already at the top. */
207
+ upgradeTier: string | null
208
+
209
+ /** Whether the user must sign up before upgrading (anonymous → registered). */
210
+ requiresSignup: boolean
211
+
212
+ /** Seconds until the client should retry, when applicable (rate-limit-style errors). */
213
+ retryAfter?: number
214
+ }
215
+ ```
216
+
217
+ #### `PlanCacheEntry`
218
+
219
+ Cached plan-key entry used by the plan cache to avoid a DB query on every
220
+ request. Bond packages and middleware should not depend on the cache shape
221
+ directly — use `getCachedPlanKey()` instead.
222
+
223
+ ```typescript
224
+ interface PlanCacheEntry {
225
+ /** The cached plan key, or `null` for free/expired plans. */
226
+ planKey: string | null
227
+
228
+ /** Absolute timestamp (ms since epoch) at which this entry expires. */
229
+ expiresAt: number
230
+
231
+ /**
232
+ * The user's stored plan expiry as read on the cache miss, or `null` when
233
+ * unset. Cached alongside the key because the row was already fetched:
234
+ * consumers that need the subscription's period boundary (e.g. an allowance
235
+ * that refreshes with the billing period) would otherwise re-read the user on
236
+ * every request, which is the exact load this cache exists to avoid. NOT the
237
+ * same as {@link PlanCacheEntry.expiresAt}, which is when the CACHE entry
238
+ * goes stale.
239
+ */
240
+ planExpiresAt: string | null
241
+ }
242
+ ```
243
+
244
+ #### `PlanCacheOptions`
245
+
246
+ Configuration options for the plan cache.
247
+
248
+ ```typescript
249
+ interface PlanCacheOptions {
250
+ /** Cache entry TTL in milliseconds. Defaults to 5 minutes. */
251
+ ttlMs?: number
252
+
253
+ /**
254
+ * Max number of cached entries. When exceeded, the oldest insertion-order
255
+ * entry is evicted on the next write. Defaults to 50,000.
256
+ */
257
+ maxEntries?: number
258
+
259
+ /**
260
+ * App-specific effective-plan-key demotion (see
261
+ * {@link EffectivePlanKeyResolver}). Applied on every cache MISS so the cached
262
+ * hot-path result already reflects the app's plan-key semantics — e.g. an
263
+ * in-app-purchase key with no expiry demoting to free. Defaults to identity.
264
+ * Pass `null` to clear a previously-set resolver back to identity.
265
+ */
266
+ effectivePlanKeyResolver?: EffectivePlanKeyResolver | null
267
+ }
268
+ ```
269
+
270
+ #### `Tier`
271
+
272
+ A subscription tier with quantitative limits.
273
+
274
+ Each application declares its own `TLimits` shape — for example, a personal
275
+ finance app might use `{ maxAccounts: number; maxTransactionsPerMonth: number }`
276
+ while a chat app might use `{ maxMessagesPerDay: number; maxParticipants: number }`.
277
+
278
+ ```typescript
279
+ interface Tier<TLimits = unknown> {
280
+ /**
281
+ * The plan key that identifies this tier. Matches the `planKey` used by
282
+ * `@molecule/api-payments` `Plan` records and by the `planKey` field on
283
+ * the `users` resource.
284
+ */
285
+ planKey: string
286
+
287
+ /**
288
+ * The tier category, used for ordering and upgrade prompts.
289
+ * Examples: `'anonymous'`, `'free'`, `'pro'`, `'team'`.
290
+ */
291
+ category: string
292
+
293
+ /** Human-readable display name shown on pricing pages and entitlement errors. */
294
+ name: string
295
+
296
+ /** Application-defined quantitative limits enforced at runtime. */
297
+ limits: TLimits
298
+ }
299
+ ```
300
+
301
+ #### `TierRegistry`
302
+
303
+ Registry of all tiers defined by an application.
304
+
305
+ Apps construct a `TierRegistry` via `defineTiers(...)` at startup and bond it
306
+ via `setProvider(registry)`. Middleware and handler code then look up the
307
+ tier for a given user via the bonded registry.
308
+
309
+ ```typescript
310
+ interface TierRegistry<TLimits = unknown> {
311
+ /**
312
+ * Look up a tier by plan key. If the key is null/undefined or unrecognized,
313
+ * returns the default tier (typically the free tier).
314
+ *
315
+ * @param planKey - The plan key to look up, or null/undefined for the default tier.
316
+ * @returns The matching tier, or the default tier when the key is unknown.
317
+ */
318
+ findTier(planKey: string | null | undefined): Tier<TLimits>
319
+
320
+ /**
321
+ * Returns the default tier — the tier applied to unauthenticated, expired,
322
+ * or unrecognized plans. Typically the free tier.
323
+ *
324
+ * @returns The default tier.
325
+ */
326
+ getDefaultTier(): Tier<TLimits>
327
+
328
+ /**
329
+ * Returns every registered tier, in registration order.
330
+ *
331
+ * @returns All registered tiers.
332
+ */
333
+ getAllTiers(): Tier<TLimits>[]
334
+
335
+ /**
336
+ * Returns the rank of a category in the upgrade order (0 = lowest).
337
+ * Returns `null` if the category was not declared in the registry's
338
+ * `categoryOrder`.
339
+ *
340
+ * @param category - The category to look up.
341
+ * @returns The zero-based rank, or `null` if not in the order.
342
+ */
343
+ getCategoryRank(category: string): number | null
344
+
345
+ /**
346
+ * Returns the next-up category in the upgrade order, or `null` if the
347
+ * category is already at the top.
348
+ *
349
+ * @param category - The starting category.
350
+ * @returns The next category up, or `null` at the top of the order.
351
+ */
352
+ getNextCategory(category: string): string | null
353
+ }
354
+ ```
355
+
356
+ #### `UserPlanFields`
357
+
358
+ Minimal user record shape consumed by the plan cache. Only the fields
359
+ needed to derive the effective plan key are required; concrete user
360
+ resources may have many more fields.
361
+
362
+ ```typescript
363
+ interface UserPlanFields {
364
+ /** The user's stored plan key, or `null`/empty for free tier. */
365
+ planKey?: string | null
366
+
367
+ /** ISO timestamp at which the current plan expires; expired plans fall back to default. */
368
+ planExpiresAt?: string | null
369
+
370
+ /** Whether the user is anonymous; anonymous users get the `'anonymous'` plan key. */
371
+ isAnonymous?: boolean
372
+ }
373
+ ```
374
+
375
+ ### Types
376
+
377
+ #### `EffectivePlanKeyResolver`
378
+
379
+ App-specific hook that maps a stored `(planKey, planExpiresAt)` pair to the
380
+ EFFECTIVE plan key — applied by {@link getCachedPlanKey} on every cache miss
381
+ before the value is cached, so the cached (hot-path) result already reflects
382
+ the app's plan-key semantics.
383
+
384
+ The cache itself only knows the generic expiry rule (a past `planExpiresAt`
385
+ demotes to default). Conventions like "an in-app-purchase key with no expiry
386
+ is unverified → demote to free" are APP-specific (the `apple*`/`google*`
387
+ prefix set is defined by the app, not this package). Apps inject that rule
388
+ here so the hot path and the app's own `resolveEffectivePlanKey` cannot
389
+ diverge — there is one demotion implementation, reused.
390
+
391
+ ```typescript
392
+ type EffectivePlanKeyResolver = (
393
+ planKey: string | null,
394
+ planExpiresAt: string | null | undefined,
395
+ ) => string | null
396
+ ```
397
+
398
+ #### `LimitType`
399
+
400
+ Identifies the kind of limit that triggered a 429-style entitlement error.
401
+ Apps may extend this with domain-specific keys via module augmentation.
402
+
403
+ ```typescript
404
+ type LimitType = string
405
+ ```
406
+
407
+ #### `RequestHandler`
408
+
409
+ Express-compatible request handler.
410
+
411
+ ```typescript
412
+ type RequestHandler = (req: Request, res: Response, next: NextFunction) => void | Promise<void>
413
+ ```
414
+
415
+ ### Functions
416
+
417
+ #### `buildLimitError(options)`
418
+
419
+ Build a `LimitErrorPayload` describing a tier-limit violation.
420
+
421
+ Reads the bonded entitlements registry to resolve the next-up category and
422
+ (optionally) the upgraded limit value. Anonymous users are flagged with
423
+ `requiresSignup: true` so the client can offer a sign-up prompt rather than
424
+ an upgrade prompt.
425
+
426
+ ```typescript
427
+ function buildLimitError(options: BuildLimitErrorOptions<TLimits>): LimitErrorPayload
428
+ ```
429
+
430
+ - `options` — The limit type, current tier category, current limit, and optional upgrade-limit resolver / retry-after / message override.
431
+
432
+ **Returns:** A structured payload safe to send as a 429 / 403 response body.
433
+
434
+ #### `clearPlanCache()`
435
+
436
+ Drops every cached plan-key entry. Intended for tests and graceful
437
+ shutdown — production code should not need to call this.
438
+
439
+ ```typescript
440
+ function clearPlanCache(): void
441
+ ```
442
+
443
+ #### `configurePlanCache(options)`
444
+
445
+ Reconfigures the plan cache. Existing entries remain; only future
446
+ insertions and TTL checks observe the new settings.
447
+
448
+ ```typescript
449
+ function configurePlanCache(options?: PlanCacheOptions): void
450
+ ```
451
+
452
+ - `options` — Optional overrides for TTL, maxEntries, and the effective-plan-key resolver.
453
+
454
+ #### `defineTiers(options)`
455
+
456
+ Constructs a `TierRegistry` from a tier record and category order.
457
+
458
+ Validates that the `defaultPlanKey` exists in the `tiers` record and that
459
+ every tier's `category` appears in `categoryOrder`. Throws synchronously
460
+ on misconfiguration so problems surface at startup, not at request time.
461
+
462
+ ```typescript
463
+ function defineTiers(options: DefineTiersOptions<TLimits>): TierRegistry<TLimits>
464
+ ```
465
+
466
+ - `options` — The tier set, default plan key, and category upgrade order.
467
+
468
+ **Returns:** A typed tier registry suitable for `setProvider(...)`.
469
+
470
+ #### `enforceLimit(options)`
471
+
472
+ Creates middleware that allows the request only when the user is below
473
+ their tier limit for the given resource. The user's tier `limits` object
474
+ supplies the cap, and the caller-supplied `getCurrent` function counts
475
+ the current usage.
476
+
477
+ ```typescript
478
+ function enforceLimit(options: EnforceLimitOptions<TLimits>): RequestHandler
479
+ ```
480
+
481
+ - `options` — The limit type, limit accessor, and current-usage accessor.
482
+
483
+ **Returns:** An Express request handler.
484
+
485
+ #### `getCachedPlanKey(userId)`
486
+
487
+ Resolve the effective plan key for a user, hitting the cache on warm reads
488
+ and falling back to a DB lookup on cache miss.
489
+
490
+ The effective plan key:
491
+
492
+ - Returns `'anonymous'` for users flagged as anonymous, regardless of stored plan.
493
+ - Returns `null` for users whose `planExpiresAt` is in the past — callers
494
+ should treat this as the default tier.
495
+ - Returns the stored plan key otherwise (or `null` if none was stored).
496
+
497
+ ```typescript
498
+ function getCachedPlanKey(userId: string): Promise<string | null>
499
+ ```
500
+
501
+ - `userId` — The user ID to look up.
502
+
503
+ **Returns:** The effective plan key, or `null` for default-tier users.
504
+
505
+ #### `getCachedPlanState(userId)`
506
+
507
+ Resolve a user's effective plan key AND the plan expiry it was derived from,
508
+ hitting the same cache {@link getCachedPlanKey} uses.
509
+
510
+ Exists because the expiry is already read on every cache miss: a consumer
511
+ that needs the subscription's period boundary — an allowance that refreshes
512
+ with the billing period, a renewal countdown — can have it for free instead
513
+ of issuing its own per-request user lookup, which is precisely the database
514
+ load this cache was introduced to remove.
515
+
516
+ `planExpiresAt` is the STORED value, not an effective one: it is `null` for
517
+ anonymous/free users and may be in the past for a plan that has just lapsed
518
+ (in which case `planKey` is already demoted to `null`).
519
+
520
+ ```typescript
521
+ function getCachedPlanState(
522
+ userId: string,
523
+ ): Promise<{ planKey: string | null; planExpiresAt: string | null }>
524
+ ```
525
+
526
+ - `userId` — The user ID to look up.
527
+
528
+ **Returns:** The effective plan key and the stored plan expiry.
529
+
530
+ #### `getEffectiveTier(res)`
531
+
532
+ Resolves the effective tier for the user attached to the request via
533
+ `res.locals.session.userId`. Falls back to the registry's default tier
534
+ when no user is on the request, when the user record cannot be found, or
535
+ when the stored plan has expired.
536
+
537
+ ```typescript
538
+ function getEffectiveTier(res: Response): Promise<Tier<TLimits>>
539
+ ```
540
+
541
+ - `res` — The response object whose `locals.session.userId` identifies the user.
542
+
543
+ **Returns:** The user's effective tier.
544
+
545
+ #### `getProvider()`
546
+
547
+ Retrieves the bonded tier registry, throwing if none is configured.
548
+
549
+ The generic parameter is the caller's responsibility — entitlements is
550
+ inherently app-specific in its `TLimits` shape, and bonds are erased at
551
+ runtime. Callers should pass their app's `TLimits` type at the call site.
552
+
553
+ ```typescript
554
+ function getProvider(): TierRegistry<TLimits>
555
+ ```
556
+
557
+ **Returns:** The bonded tier registry.
558
+
559
+ #### `hasProvider()`
560
+
561
+ Checks whether an entitlements provider is currently bonded.
562
+
563
+ ```typescript
564
+ function hasProvider(): boolean
565
+ ```
566
+
567
+ **Returns:** `true` if a tier registry is bonded.
568
+
569
+ #### `invalidateCachedPlanKey(userId)`
570
+
571
+ Invalidate a single user's cached plan-key entry. Call this immediately
572
+ after writing a new `planKey` / `planExpiresAt` to the user record (e.g.
573
+ from a webhook handler) so the next request reflects the change without
574
+ waiting out the TTL.
575
+
576
+ ```typescript
577
+ function invalidateCachedPlanKey(userId: string): void
578
+ ```
579
+
580
+ - `userId` — The user ID whose cache entry should be evicted.
581
+
582
+ #### `planCacheSize()`
583
+
584
+ Returns the number of currently cached entries. Mainly useful for tests
585
+ and operational metrics.
586
+
587
+ ```typescript
588
+ function planCacheSize(): number
589
+ ```
590
+
591
+ **Returns:** Current cache size.
592
+
593
+ #### `requireCategory(allowedCategories)`
594
+
595
+ Creates middleware that allows the request only when the user's tier
596
+ category is one of the listed values. Responds 401 if the request is
597
+ unauthenticated, 403 with a `LimitErrorPayload`-shaped body if the user's
598
+ tier is not in the list.
599
+
600
+ ```typescript
601
+ function requireCategory(allowedCategories?: string[]): RequestHandler
602
+ ```
603
+
604
+ - `allowedCategories` — The tier categories that are permitted (e.g. `['pro', 'team']`).
605
+
606
+ **Returns:** An Express request handler.
607
+
608
+ #### `requireCategoryAtLeast(minCategory)`
609
+
610
+ Creates middleware that allows the request only when the user's tier
611
+ rank is at least as high as the named category. Useful for "pro and above"
612
+ style gates without listing every category individually.
613
+
614
+ Apps must include all gated categories in `categoryOrder` when calling
615
+ `defineTiers(...)`; categories absent from the order produce `null` ranks
616
+ and therefore fail the check.
617
+
618
+ ```typescript
619
+ function requireCategoryAtLeast(minCategory: string): RequestHandler
620
+ ```
621
+
622
+ - `minCategory` — The minimum acceptable category.
623
+
624
+ **Returns:** An Express request handler.
625
+
626
+ #### `setProvider(provider)`
627
+
628
+ Registers a tier registry as the active entitlements provider.
629
+ Called by the application during startup.
630
+
631
+ ```typescript
632
+ function setProvider(provider: TierRegistry<TLimits>): void
633
+ ```
634
+
635
+ - `provider` — The tier registry to bond.
636
+
637
+ #### `sweepExpiredPlanCacheEntries()`
638
+
639
+ Sweep expired entries from the cache. Safe to call from a recurring
640
+ cleanup interval; idempotent and O(n) in cache size.
641
+
642
+ ```typescript
643
+ function sweepExpiredPlanCacheEntries(): void
644
+ ```
645
+
646
+ ## Injection Notes
647
+
648
+ ### Requirements
649
+
650
+ Peer dependencies:
651
+
652
+ - `@molecule/api-bond` ^1.0.1
653
+ - `@molecule/api-database` ^1.0.1
654
+ - `@molecule/api-i18n` ^1.0.1
655
+ - `@molecule/api-rate-limit` ^1.0.1
656
+
657
+ ### Runtime Dependencies
658
+
659
+ - `@molecule/api-bond`
660
+ - `@molecule/api-database`
661
+ - `@molecule/api-i18n`
662
+ - `@molecule/api-rate-limit`
663
+
664
+ - **Enforcement is middleware on the API route** (`requireCategory`,
665
+ `requireCategoryAtLeast`, `enforceLimit`) — hiding a button in the UI is
666
+ not entitlement enforcement. The middleware reads the authenticated user
667
+ from `res.locals.session.userId`, so it must be registered AFTER the auth
668
+ middleware; unauthenticated requests get a 401.
669
+ - **`enforceLimit` blocks at `current >= limit`** and responds with a
670
+ structured `LimitErrorPayload` (default 403; pass `status: 429` for
671
+ usage-style limits) that the app's limit/upgrade notice renders — don't
672
+ swallow it into a generic error page.
673
+ - **It is a SOFT ceiling — `getCurrent` COUNTS, then the handler CREATES the
674
+ resource afterwards.** Under concurrency N requests can all read the same
675
+ `current < limit` and all create, so the limit can be exceeded by a few.
676
+ That is fine for plan limits (max projects / seats / collaborators — a
677
+ bounded, harmless overshoot). It is NOT enough for a HARD limit where going
678
+ over is a real loss: money / wallet balances, physical inventory (stock,
679
+ tickets, seats), or metered credits. Enforce THOSE atomically at the write
680
+ with a conditional `UPDATE ... WHERE remaining >= $n RETURNING` that affects
681
+ 0 rows when it wouldn't fit (or an advisory-lock reserve for a ledger SUM) —
682
+ never a count-then-allow middleware.
683
+ - **Plan keys are cached per process** (default 5-minute TTL). The
684
+ resource-user payment webhook glue invalidates on plan change; any custom
685
+ path that mutates a user's `planKey` must call
686
+ `invalidateCachedPlanKey(userId)` or the old tier lingers until TTL.
687
+ - Unknown, expired, or missing plan keys resolve to the `defaultPlanKey`
688
+ tier — make the default tier's limits the safe floor.
689
+ - The middleware factories are connect/Express-shaped conveniences. Other
690
+ stacks (queues, websockets, non-Express frameworks) enforce the same tiers
691
+ directly via `getProvider()` + `getCachedPlanKey(userId)`.
692
+
693
+ ## E2E Tests
694
+
695
+ Integration checklist — drive the real UI (live preview, no mocks), adapt
696
+ each item to this app's actual screens/flows, and check every box off one
697
+ by one. A box you can't check is an integration bug to fix — not a skip:
698
+
699
+ - [ ] The pricing/plans surface lists every tier with its name, price, and limits
700
+ (rendered from `/api/billing/tiers`, not hardcoded).
701
+ - [ ] A free-tier user who hits a quantitative limit (e.g. creates the max
702
+ allowed items, then one more) gets a visible limit/upgrade notice — never a
703
+ silent failure, a blank page, or a raw 500.
704
+ - [ ] The blocked action really is blocked server-side: after a full page reload
705
+ the over-limit item was NOT created.
706
+ - [ ] A higher-tier user (seed or upgrade one) can perform the same action that
707
+ was blocked on the free tier.
708
+ - [ ] Tier-gated features/sections are hidden or clearly locked for tiers that
709
+ lack them, and usable for tiers that have them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-entitlements",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Tier-based entitlements core interface for molecule.dev: typed quantitative limits per subscription plan, plan-key cache, and tier-aware middleware factories",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,7 +17,8 @@
17
17
  }
18
18
  },
19
19
  "files": [
20
- "dist"
20
+ "dist",
21
+ "README.md"
21
22
  ],
22
23
  "keywords": [
23
24
  "molecule",
@@ -29,19 +30,19 @@
29
30
  ],
30
31
  "license": "Apache-2.0",
31
32
  "peerDependencies": {
32
- "@molecule/api-bond": "^1.0.0",
33
- "@molecule/api-database": "^1.0.0",
34
- "@molecule/api-i18n": "^1.0.0",
35
- "@molecule/api-rate-limit": "^1.0.0"
33
+ "@molecule/api-bond": "^1.0.1",
34
+ "@molecule/api-database": "^1.0.1",
35
+ "@molecule/api-i18n": "^1.0.1",
36
+ "@molecule/api-rate-limit": "^1.0.1"
36
37
  },
37
38
  "devDependencies": {
38
- "@molecule/api-bond": "1.0.0",
39
- "@molecule/api-database": "1.0.0",
40
- "@molecule/api-i18n": "1.0.0",
41
- "@molecule/api-payments": "1.0.0",
42
- "@molecule/api-payments-stripe": "1.0.0",
43
- "@molecule/api-rate-limit": "1.0.0",
44
- "@molecule/api-resource": "1.0.0",
39
+ "@molecule/api-bond": "1.0.1",
40
+ "@molecule/api-database": "1.0.1",
41
+ "@molecule/api-i18n": "1.0.1",
42
+ "@molecule/api-payments": "1.0.1",
43
+ "@molecule/api-payments-stripe": "1.0.1",
44
+ "@molecule/api-rate-limit": "1.0.1",
45
+ "@molecule/api-resource": "1.0.1",
45
46
  "@types/node": "26.1.2",
46
47
  "stripe": "22.4.0",
47
48
  "typescript": "6.0.3",