@molecule/api-entitlements 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/LICENSE +115 -0
- package/dist/browser-guard.d.ts +2 -0
- package/dist/browser-guard.d.ts.map +1 -0
- package/dist/browser-guard.js +19 -0
- package/dist/browser-guard.js.map +1 -0
- package/dist/cache.d.ts +120 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +163 -0
- package/dist/cache.js.map +1 -0
- package/dist/error.d.ts +43 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +55 -0
- package/dist/error.js.map +1 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +101 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware.d.ts +129 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +155 -0
- package/dist/middleware.js.map +1 -0
- package/dist/provider.d.ts +35 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +51 -0
- package/dist/provider.js.map +1 -0
- package/dist/registry.d.ts +41 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +88 -0
- package/dist/registry.js.map +1 -0
- package/dist/types.d.ts +177 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/package.json +60 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier-based entitlements core for molecule.dev.
|
|
3
|
+
*
|
|
4
|
+
* Provides the typed `Tier<TLimits>` / `TierRegistry<TLimits>` shapes, a
|
|
5
|
+
* per-process plan-key cache, and Express middleware factories that gate
|
|
6
|
+
* endpoints by tier category or quantitative limit.
|
|
7
|
+
*
|
|
8
|
+
* Apps declare their own `TLimits` shape, construct a registry via
|
|
9
|
+
* `defineTiers(...)`, and bond it via `setProvider(...)` at startup. The
|
|
10
|
+
* webhook glue that maps Stripe / Apple / Google subscription events to
|
|
11
|
+
* `users.planKey` already lives in `@molecule/api-resource-user`.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* import { defineTiers, setProvider, enforceLimit, requireCategoryAtLeast } from '@molecule/api-entitlements'
|
|
16
|
+
* import { count } from '@molecule/api-database'
|
|
17
|
+
*
|
|
18
|
+
* interface BlogLimits {
|
|
19
|
+
* maxPosts: number
|
|
20
|
+
* maxCommentsPerDay: number
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* const registry = defineTiers<BlogLimits>({
|
|
24
|
+
* tiers: {
|
|
25
|
+
* free: { planKey: 'free', category: 'free', name: 'Free', limits: { maxPosts: 5, maxCommentsPerDay: 50 } },
|
|
26
|
+
* stripeMonthly: { planKey: 'stripeMonthly', category: 'pro', name: 'Pro', limits: { maxPosts: 100, maxCommentsPerDay: 1000 } },
|
|
27
|
+
* },
|
|
28
|
+
* defaultPlanKey: 'free',
|
|
29
|
+
* categoryOrder: ['free', 'pro'],
|
|
30
|
+
* })
|
|
31
|
+
*
|
|
32
|
+
* setProvider(registry)
|
|
33
|
+
*
|
|
34
|
+
* // Gate the API routes — the SERVER enforces tiers, never the UI alone:
|
|
35
|
+
* router.post('/posts',
|
|
36
|
+
* enforceLimit<BlogLimits>({
|
|
37
|
+
* limitType: 'maxPosts',
|
|
38
|
+
* getLimit: (limits) => limits.maxPosts,
|
|
39
|
+
* getCurrent: (userId) => count('posts', [{ field: 'userId', operator: '=', value: userId }]),
|
|
40
|
+
* }),
|
|
41
|
+
* handlers.createPost,
|
|
42
|
+
* )
|
|
43
|
+
* router.get('/analytics', requireCategoryAtLeast('pro'), handlers.analytics)
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* @remarks
|
|
47
|
+
* - **Enforcement is middleware on the API route** (`requireCategory`,
|
|
48
|
+
* `requireCategoryAtLeast`, `enforceLimit`) — hiding a button in the UI is
|
|
49
|
+
* not entitlement enforcement. The middleware reads the authenticated user
|
|
50
|
+
* from `res.locals.session.userId`, so it must be registered AFTER the auth
|
|
51
|
+
* middleware; unauthenticated requests get a 401.
|
|
52
|
+
* - **`enforceLimit` blocks at `current >= limit`** and responds with a
|
|
53
|
+
* structured `LimitErrorPayload` (default 403; pass `status: 429` for
|
|
54
|
+
* usage-style limits) that the app's limit/upgrade notice renders — don't
|
|
55
|
+
* swallow it into a generic error page.
|
|
56
|
+
* - **It is a SOFT ceiling — `getCurrent` COUNTS, then the handler CREATES the
|
|
57
|
+
* resource afterwards.** Under concurrency N requests can all read the same
|
|
58
|
+
* `current < limit` and all create, so the limit can be exceeded by a few.
|
|
59
|
+
* That is fine for plan limits (max projects / seats / collaborators — a
|
|
60
|
+
* bounded, harmless overshoot). It is NOT enough for a HARD limit where going
|
|
61
|
+
* over is a real loss: money / wallet balances, physical inventory (stock,
|
|
62
|
+
* tickets, seats), or metered credits. Enforce THOSE atomically at the write
|
|
63
|
+
* with a conditional `UPDATE ... WHERE remaining >= $n RETURNING` that affects
|
|
64
|
+
* 0 rows when it wouldn't fit (or an advisory-lock reserve for a ledger SUM) —
|
|
65
|
+
* never a count-then-allow middleware.
|
|
66
|
+
* - **Plan keys are cached per process** (default 5-minute TTL). The
|
|
67
|
+
* resource-user payment webhook glue invalidates on plan change; any custom
|
|
68
|
+
* path that mutates a user's `planKey` must call
|
|
69
|
+
* `invalidateCachedPlanKey(userId)` or the old tier lingers until TTL.
|
|
70
|
+
* - Unknown, expired, or missing plan keys resolve to the `defaultPlanKey`
|
|
71
|
+
* tier — make the default tier's limits the safe floor.
|
|
72
|
+
* - The middleware factories are connect/Express-shaped conveniences. Other
|
|
73
|
+
* stacks (queues, websockets, non-Express frameworks) enforce the same tiers
|
|
74
|
+
* directly via `getProvider()` + `getCachedPlanKey(userId)`.
|
|
75
|
+
*
|
|
76
|
+
* @e2e
|
|
77
|
+
* Integration checklist — drive the real UI (live preview, no mocks), adapt
|
|
78
|
+
* each item to this app's actual screens/flows, and check every box off one
|
|
79
|
+
* by one. A box you can't check is an integration bug to fix — not a skip:
|
|
80
|
+
* - [ ] The pricing/plans surface lists every tier with its name, price, and limits
|
|
81
|
+
* (rendered from `/api/billing/tiers`, not hardcoded).
|
|
82
|
+
* - [ ] A free-tier user who hits a quantitative limit (e.g. creates the max
|
|
83
|
+
* allowed items, then one more) gets a visible limit/upgrade notice — never a
|
|
84
|
+
* silent failure, a blank page, or a raw 500.
|
|
85
|
+
* - [ ] The blocked action really is blocked server-side: after a full page reload
|
|
86
|
+
* the over-limit item was NOT created.
|
|
87
|
+
* - [ ] A higher-tier user (seed or upgrade one) can perform the same action that
|
|
88
|
+
* was blocked on the free tier.
|
|
89
|
+
* - [ ] Tier-gated features/sections are hidden or clearly locked for tiers that
|
|
90
|
+
* lack them, and usable for tiers that have them.
|
|
91
|
+
*
|
|
92
|
+
* @module
|
|
93
|
+
*/
|
|
94
|
+
export * from './browser-guard.js';
|
|
95
|
+
export * from './cache.js';
|
|
96
|
+
export * from './error.js';
|
|
97
|
+
export * from './middleware.js';
|
|
98
|
+
export * from './provider.js';
|
|
99
|
+
export * from './registry.js';
|
|
100
|
+
export * from './types.js';
|
|
101
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4FG;AAEH,cAAc,oBAAoB,CAAA;AAClC,cAAc,YAAY,CAAA;AAC1B,cAAc,YAAY,CAAA;AAC1B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express middleware factories for tier-based entitlements.
|
|
3
|
+
*
|
|
4
|
+
* `requireCategory` and `requireCategoryAtLeast` gate endpoints by the user's
|
|
5
|
+
* tier category. `enforceLimit` checks a quantitative limit before allowing
|
|
6
|
+
* the request to proceed (e.g. "max 10 projects per user").
|
|
7
|
+
*
|
|
8
|
+
* Tier-aware windowed rate limiting (e.g. "60 requests per minute on free,
|
|
9
|
+
* 300 on pro") is intentionally not handled here — wire one
|
|
10
|
+
* `@molecule/api-rate-limit` middleware per tier and dispatch on category,
|
|
11
|
+
* or use the bonded rate-limit provider directly.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
import type { LimitType, Tier } from './types.js';
|
|
16
|
+
/**
|
|
17
|
+
* Minimal request shape consumed by the entitlements middleware. The
|
|
18
|
+
* structural type lets Express's full `Request` flow in transparently
|
|
19
|
+
* (Express types are assignable to this) without forcing this package to
|
|
20
|
+
* take a hard dependency on `express`.
|
|
21
|
+
*
|
|
22
|
+
* The middleware never reads off `req` directly; auth context lives on
|
|
23
|
+
* `res.locals.session.userId` and is read via the response object instead.
|
|
24
|
+
* The optional `_skipReason` field exists purely so this is not an empty
|
|
25
|
+
* interface (which ESLint's `@typescript-eslint/no-empty-interface` flags).
|
|
26
|
+
*/
|
|
27
|
+
interface Request {
|
|
28
|
+
/** Reserved — not consumed by the middleware. */
|
|
29
|
+
readonly _skipReason?: never;
|
|
30
|
+
}
|
|
31
|
+
/** Minimal response shape — Express's `Response` is assignable to this. */
|
|
32
|
+
interface Response {
|
|
33
|
+
status(code: number): Response;
|
|
34
|
+
json(body: unknown): Response;
|
|
35
|
+
set(field: string, value: string): Response;
|
|
36
|
+
locals: {
|
|
37
|
+
session?: {
|
|
38
|
+
userId?: string;
|
|
39
|
+
};
|
|
40
|
+
} & Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
/** Express-compatible next function. */
|
|
43
|
+
type NextFunction = (err?: unknown) => void;
|
|
44
|
+
/** Express-compatible request handler. */
|
|
45
|
+
export type RequestHandler = (req: Request, res: Response, next: NextFunction) => void | Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Resolves the effective tier for the user attached to the request via
|
|
48
|
+
* `res.locals.session.userId`. Falls back to the registry's default tier
|
|
49
|
+
* when no user is on the request, when the user record cannot be found, or
|
|
50
|
+
* when the stored plan has expired.
|
|
51
|
+
*
|
|
52
|
+
* @param res - The response object whose `locals.session.userId` identifies the user.
|
|
53
|
+
* @returns The user's effective tier.
|
|
54
|
+
*/
|
|
55
|
+
export declare const getEffectiveTier: <TLimits = unknown>(res: Response) => Promise<Tier<TLimits>>;
|
|
56
|
+
/**
|
|
57
|
+
* Creates middleware that allows the request only when the user's tier
|
|
58
|
+
* category is one of the listed values. Responds 401 if the request is
|
|
59
|
+
* unauthenticated, 403 with a `LimitErrorPayload`-shaped body if the user's
|
|
60
|
+
* tier is not in the list.
|
|
61
|
+
*
|
|
62
|
+
* @param allowedCategories - The tier categories that are permitted (e.g. `['pro', 'team']`).
|
|
63
|
+
* @returns An Express request handler.
|
|
64
|
+
*/
|
|
65
|
+
export declare const requireCategory: <TLimits = unknown>(...allowedCategories: string[]) => RequestHandler;
|
|
66
|
+
/**
|
|
67
|
+
* Creates middleware that allows the request only when the user's tier
|
|
68
|
+
* rank is at least as high as the named category. Useful for "pro and above"
|
|
69
|
+
* style gates without listing every category individually.
|
|
70
|
+
*
|
|
71
|
+
* Apps must include all gated categories in `categoryOrder` when calling
|
|
72
|
+
* `defineTiers(...)`; categories absent from the order produce `null` ranks
|
|
73
|
+
* and therefore fail the check.
|
|
74
|
+
*
|
|
75
|
+
* @param minCategory - The minimum acceptable category.
|
|
76
|
+
* @returns An Express request handler.
|
|
77
|
+
*/
|
|
78
|
+
export declare const requireCategoryAtLeast: <TLimits = unknown>(minCategory: string) => RequestHandler;
|
|
79
|
+
/** Options for the `enforceLimit` middleware. */
|
|
80
|
+
export interface EnforceLimitOptions<TLimits = unknown> {
|
|
81
|
+
/** Stable identifier for the limit (used in error payloads, telemetry). */
|
|
82
|
+
limitType: LimitType;
|
|
83
|
+
/**
|
|
84
|
+
* Pulls the numeric cap out of the user's tier `limits` object.
|
|
85
|
+
*
|
|
86
|
+
* @param limits - The tier-specific limits.
|
|
87
|
+
* @returns The numeric cap to enforce.
|
|
88
|
+
*/
|
|
89
|
+
getLimit: (limits: TLimits) => number;
|
|
90
|
+
/**
|
|
91
|
+
* Computes the user's current usage. Receives the userId resolved from the
|
|
92
|
+
* session and the request object so apps can scope by additional fields
|
|
93
|
+
* (e.g. organization, project) when needed.
|
|
94
|
+
*
|
|
95
|
+
* @param userId - The authenticated user ID.
|
|
96
|
+
* @param req - The incoming request, in case scoping needs query/body data.
|
|
97
|
+
* @returns The current usage count.
|
|
98
|
+
*/
|
|
99
|
+
getCurrent: (userId: string, req: Request) => Promise<number> | number;
|
|
100
|
+
/**
|
|
101
|
+
* Optional override for the response status. Defaults to 403; some apps
|
|
102
|
+
* prefer 429 for usage-style limits.
|
|
103
|
+
*/
|
|
104
|
+
status?: number;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Creates middleware that allows the request only when the user is below
|
|
108
|
+
* their tier limit for the given resource. The user's tier `limits` object
|
|
109
|
+
* supplies the cap, and the caller-supplied `getCurrent` function counts
|
|
110
|
+
* the current usage.
|
|
111
|
+
*
|
|
112
|
+
* @param options - The limit type, limit accessor, and current-usage accessor.
|
|
113
|
+
* @returns An Express request handler.
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```typescript
|
|
117
|
+
* router.post('/posts',
|
|
118
|
+
* enforceLimit<BlogLimits>({
|
|
119
|
+
* limitType: 'maxPosts',
|
|
120
|
+
* getLimit: (limits) => limits.maxPosts,
|
|
121
|
+
* getCurrent: (userId) => count('posts', [{ field: 'userId', operator: '=', value: userId }]),
|
|
122
|
+
* }),
|
|
123
|
+
* handlers.createPost,
|
|
124
|
+
* )
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
export declare const enforceLimit: <TLimits = unknown>(options: EnforceLimitOptions<TLimits>) => RequestHandler;
|
|
128
|
+
export {};
|
|
129
|
+
//# sourceMappingURL=middleware.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAOH,OAAO,KAAK,EAAqB,SAAS,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAEpE;;;;;;;;;;GAUG;AACH,UAAU,OAAO;IACf,iDAAiD;IACjD,QAAQ,CAAC,WAAW,CAAC,EAAE,KAAK,CAAA;CAC7B;AAED,2EAA2E;AAC3E,UAAU,QAAQ;IAChB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,CAAA;IAC9B,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAA;IAC7B,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,QAAQ,CAAA;IAC3C,MAAM,EAAE;QAAE,OAAO,CAAC,EAAE;YAAE,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACpE;AAED,wCAAwC;AACxC,KAAK,YAAY,GAAG,CAAC,GAAG,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;AAE3C,0CAA0C;AAC1C,MAAM,MAAM,cAAc,GAAG,CAC3B,GAAG,EAAE,OAAO,EACZ,GAAG,EAAE,QAAQ,EACb,IAAI,EAAE,YAAY,KACf,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;AAEzB;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,GAAU,OAAO,GAAG,OAAO,EACtD,KAAK,QAAQ,KACZ,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAOvB,CAAA;AAaD;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,GAAG,OAAO,EAC/C,GAAG,mBAAmB,MAAM,EAAE,KAC7B,cAqBF,CAAA;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,sBAAsB,GAAI,OAAO,GAAG,OAAO,EAAE,aAAa,MAAM,KAAG,cA0B/E,CAAA;AAED,iDAAiD;AACjD,MAAM,WAAW,mBAAmB,CAAC,OAAO,GAAG,OAAO;IACpD,2EAA2E;IAC3E,SAAS,EAAE,SAAS,CAAA;IAEpB;;;;;OAKG;IACH,QAAQ,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,MAAM,CAAA;IAErC;;;;;;;;OAQG;IACH,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAA;IAEtE;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,GAAG,OAAO,EAC5C,SAAS,mBAAmB,CAAC,OAAO,CAAC,KACpC,cA2BF,CAAA"}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express middleware factories for tier-based entitlements.
|
|
3
|
+
*
|
|
4
|
+
* `requireCategory` and `requireCategoryAtLeast` gate endpoints by the user's
|
|
5
|
+
* tier category. `enforceLimit` checks a quantitative limit before allowing
|
|
6
|
+
* the request to proceed (e.g. "max 10 projects per user").
|
|
7
|
+
*
|
|
8
|
+
* Tier-aware windowed rate limiting (e.g. "60 requests per minute on free,
|
|
9
|
+
* 300 on pro") is intentionally not handled here — wire one
|
|
10
|
+
* `@molecule/api-rate-limit` middleware per tier and dispatch on category,
|
|
11
|
+
* or use the bonded rate-limit provider directly.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
import { t } from '@molecule/api-i18n';
|
|
16
|
+
import { getCachedPlanKey } from './cache.js';
|
|
17
|
+
import { buildLimitError } from './error.js';
|
|
18
|
+
import { getProvider } from './provider.js';
|
|
19
|
+
/**
|
|
20
|
+
* Resolves the effective tier for the user attached to the request via
|
|
21
|
+
* `res.locals.session.userId`. Falls back to the registry's default tier
|
|
22
|
+
* when no user is on the request, when the user record cannot be found, or
|
|
23
|
+
* when the stored plan has expired.
|
|
24
|
+
*
|
|
25
|
+
* @param res - The response object whose `locals.session.userId` identifies the user.
|
|
26
|
+
* @returns The user's effective tier.
|
|
27
|
+
*/
|
|
28
|
+
export const getEffectiveTier = async (res) => {
|
|
29
|
+
const registry = getProvider();
|
|
30
|
+
const userId = res.locals?.session?.userId;
|
|
31
|
+
if (!userId)
|
|
32
|
+
return registry.getDefaultTier();
|
|
33
|
+
const planKey = await getCachedPlanKey(userId);
|
|
34
|
+
return registry.findTier(planKey);
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Builds a 401 response payload for unauthenticated requests.
|
|
38
|
+
*
|
|
39
|
+
* @returns An error envelope shaped like `LimitErrorPayload` for client uniformity.
|
|
40
|
+
*/
|
|
41
|
+
const buildUnauthorizedPayload = () => ({
|
|
42
|
+
error: t('entitlements.error.unauthenticated', undefined, {
|
|
43
|
+
defaultValue: 'Authentication required.',
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
/**
|
|
47
|
+
* Creates middleware that allows the request only when the user's tier
|
|
48
|
+
* category is one of the listed values. Responds 401 if the request is
|
|
49
|
+
* unauthenticated, 403 with a `LimitErrorPayload`-shaped body if the user's
|
|
50
|
+
* tier is not in the list.
|
|
51
|
+
*
|
|
52
|
+
* @param allowedCategories - The tier categories that are permitted (e.g. `['pro', 'team']`).
|
|
53
|
+
* @returns An Express request handler.
|
|
54
|
+
*/
|
|
55
|
+
export const requireCategory = (...allowedCategories) => {
|
|
56
|
+
return async (_req, res, next) => {
|
|
57
|
+
const userId = res.locals?.session?.userId;
|
|
58
|
+
if (!userId) {
|
|
59
|
+
res.status(401).json(buildUnauthorizedPayload());
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const tier = await getEffectiveTier(res);
|
|
63
|
+
if (!allowedCategories.includes(tier.category)) {
|
|
64
|
+
const payload = buildLimitError({
|
|
65
|
+
limitType: 'category',
|
|
66
|
+
category: tier.category,
|
|
67
|
+
currentLimit: 0,
|
|
68
|
+
});
|
|
69
|
+
res.status(403).json(payload);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
next();
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Creates middleware that allows the request only when the user's tier
|
|
77
|
+
* rank is at least as high as the named category. Useful for "pro and above"
|
|
78
|
+
* style gates without listing every category individually.
|
|
79
|
+
*
|
|
80
|
+
* Apps must include all gated categories in `categoryOrder` when calling
|
|
81
|
+
* `defineTiers(...)`; categories absent from the order produce `null` ranks
|
|
82
|
+
* and therefore fail the check.
|
|
83
|
+
*
|
|
84
|
+
* @param minCategory - The minimum acceptable category.
|
|
85
|
+
* @returns An Express request handler.
|
|
86
|
+
*/
|
|
87
|
+
export const requireCategoryAtLeast = (minCategory) => {
|
|
88
|
+
return async (_req, res, next) => {
|
|
89
|
+
const userId = res.locals?.session?.userId;
|
|
90
|
+
if (!userId) {
|
|
91
|
+
res.status(401).json(buildUnauthorizedPayload());
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const registry = getProvider();
|
|
95
|
+
const minRank = registry.getCategoryRank(minCategory);
|
|
96
|
+
const tier = await getEffectiveTier(res);
|
|
97
|
+
const userRank = registry.getCategoryRank(tier.category);
|
|
98
|
+
if (minRank == null || userRank == null || userRank < minRank) {
|
|
99
|
+
const payload = buildLimitError({
|
|
100
|
+
limitType: 'categoryAtLeast',
|
|
101
|
+
category: tier.category,
|
|
102
|
+
currentLimit: userRank ?? 0,
|
|
103
|
+
});
|
|
104
|
+
res.status(403).json(payload);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
next();
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Creates middleware that allows the request only when the user is below
|
|
112
|
+
* their tier limit for the given resource. The user's tier `limits` object
|
|
113
|
+
* supplies the cap, and the caller-supplied `getCurrent` function counts
|
|
114
|
+
* the current usage.
|
|
115
|
+
*
|
|
116
|
+
* @param options - The limit type, limit accessor, and current-usage accessor.
|
|
117
|
+
* @returns An Express request handler.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```typescript
|
|
121
|
+
* router.post('/posts',
|
|
122
|
+
* enforceLimit<BlogLimits>({
|
|
123
|
+
* limitType: 'maxPosts',
|
|
124
|
+
* getLimit: (limits) => limits.maxPosts,
|
|
125
|
+
* getCurrent: (userId) => count('posts', [{ field: 'userId', operator: '=', value: userId }]),
|
|
126
|
+
* }),
|
|
127
|
+
* handlers.createPost,
|
|
128
|
+
* )
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
export const enforceLimit = (options) => {
|
|
132
|
+
const { limitType, getLimit, getCurrent, status = 403 } = options;
|
|
133
|
+
return async (req, res, next) => {
|
|
134
|
+
const userId = res.locals?.session?.userId;
|
|
135
|
+
if (!userId) {
|
|
136
|
+
res.status(401).json(buildUnauthorizedPayload());
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const tier = await getEffectiveTier(res);
|
|
140
|
+
const limit = getLimit(tier.limits);
|
|
141
|
+
const current = await getCurrent(userId, req);
|
|
142
|
+
if (current >= limit) {
|
|
143
|
+
const payload = buildLimitError({
|
|
144
|
+
limitType,
|
|
145
|
+
category: tier.category,
|
|
146
|
+
currentLimit: limit,
|
|
147
|
+
resolveUpgradedLimit: getLimit,
|
|
148
|
+
});
|
|
149
|
+
res.status(status).json(payload);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
next();
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
//# sourceMappingURL=middleware.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"middleware.js","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entitlements provider bond accessor.
|
|
3
|
+
*
|
|
4
|
+
* Apps construct a `TierRegistry` (typically via `defineTiers(...)`) and call
|
|
5
|
+
* `setProvider(registry)` at startup. Handler code, middleware, and any other
|
|
6
|
+
* consumer then resolves the bonded registry via `getProvider<TLimits>()`.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
import type { TierRegistry } from './types.js';
|
|
11
|
+
/**
|
|
12
|
+
* Registers a tier registry as the active entitlements provider.
|
|
13
|
+
* Called by the application during startup.
|
|
14
|
+
*
|
|
15
|
+
* @param provider - The tier registry to bond.
|
|
16
|
+
*/
|
|
17
|
+
export declare const setProvider: <TLimits = unknown>(provider: TierRegistry<TLimits>) => void;
|
|
18
|
+
/**
|
|
19
|
+
* Retrieves the bonded tier registry, throwing if none is configured.
|
|
20
|
+
*
|
|
21
|
+
* The generic parameter is the caller's responsibility — entitlements is
|
|
22
|
+
* inherently app-specific in its `TLimits` shape, and bonds are erased at
|
|
23
|
+
* runtime. Callers should pass their app's `TLimits` type at the call site.
|
|
24
|
+
*
|
|
25
|
+
* @returns The bonded tier registry.
|
|
26
|
+
* @throws {Error} If no entitlements provider has been bonded.
|
|
27
|
+
*/
|
|
28
|
+
export declare const getProvider: <TLimits = unknown>() => TierRegistry<TLimits>;
|
|
29
|
+
/**
|
|
30
|
+
* Checks whether an entitlements provider is currently bonded.
|
|
31
|
+
*
|
|
32
|
+
* @returns `true` if a tier registry is bonded.
|
|
33
|
+
*/
|
|
34
|
+
export declare const hasProvider: () => boolean;
|
|
35
|
+
//# sourceMappingURL=provider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAK9C;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,OAAO,GAAG,OAAO,EAAE,UAAU,YAAY,CAAC,OAAO,CAAC,KAAG,IAEhF,CAAA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,WAAW,GAAI,OAAO,GAAG,OAAO,OAAK,YAAY,CAAC,OAAO,CAYrE,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,WAAW,QAAO,OAE9B,CAAA"}
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entitlements provider bond accessor.
|
|
3
|
+
*
|
|
4
|
+
* Apps construct a `TierRegistry` (typically via `defineTiers(...)`) and call
|
|
5
|
+
* `setProvider(registry)` at startup. Handler code, middleware, and any other
|
|
6
|
+
* consumer then resolves the bonded registry via `getProvider<TLimits>()`.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
import { bond, expectBond, isBonded, require as bondRequire } from '@molecule/api-bond';
|
|
11
|
+
import { t } from '@molecule/api-i18n';
|
|
12
|
+
const BOND_TYPE = 'entitlements';
|
|
13
|
+
expectBond(BOND_TYPE);
|
|
14
|
+
/**
|
|
15
|
+
* Registers a tier registry as the active entitlements provider.
|
|
16
|
+
* Called by the application during startup.
|
|
17
|
+
*
|
|
18
|
+
* @param provider - The tier registry to bond.
|
|
19
|
+
*/
|
|
20
|
+
export const setProvider = (provider) => {
|
|
21
|
+
bond(BOND_TYPE, provider);
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Retrieves the bonded tier registry, throwing if none is configured.
|
|
25
|
+
*
|
|
26
|
+
* The generic parameter is the caller's responsibility — entitlements is
|
|
27
|
+
* inherently app-specific in its `TLimits` shape, and bonds are erased at
|
|
28
|
+
* runtime. Callers should pass their app's `TLimits` type at the call site.
|
|
29
|
+
*
|
|
30
|
+
* @returns The bonded tier registry.
|
|
31
|
+
* @throws {Error} If no entitlements provider has been bonded.
|
|
32
|
+
*/
|
|
33
|
+
export const getProvider = () => {
|
|
34
|
+
try {
|
|
35
|
+
return bondRequire(BOND_TYPE);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
throw new Error(t('entitlements.error.noProvider', undefined, {
|
|
39
|
+
defaultValue: 'Entitlements provider not configured. Call setProvider(tierRegistry) at startup.',
|
|
40
|
+
}), { cause: error });
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Checks whether an entitlements provider is currently bonded.
|
|
45
|
+
*
|
|
46
|
+
* @returns `true` if a tier registry is bonded.
|
|
47
|
+
*/
|
|
48
|
+
export const hasProvider = () => {
|
|
49
|
+
return isBonded(BOND_TYPE);
|
|
50
|
+
};
|
|
51
|
+
//# sourceMappingURL=provider.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"provider.js","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,oBAAoB,CAAA;AACvF,OAAO,EAAE,CAAC,EAAE,MAAM,oBAAoB,CAAA;AAItC,MAAM,SAAS,GAAG,cAAc,CAAA;AAChC,UAAU,CAAC,SAAS,CAAC,CAAA;AAErB;;;;;GAKG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAoB,QAA+B,EAAQ,EAAE;IACtF,IAAI,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAA;AAC3B,CAAC,CAAA;AAED;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,GAA6C,EAAE;IACxE,IAAI,CAAC;QACH,OAAO,WAAW,CAAwB,SAAS,CAAC,CAAA;IACtD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,KAAK,CACb,CAAC,CAAC,+BAA+B,EAAE,SAAS,EAAE;YAC5C,YAAY,EACV,kFAAkF;SACrF,CAAC,EACF,EAAE,KAAK,EAAE,KAAK,EAAE,CACjB,CAAA;IACH,CAAC;AACH,CAAC,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,GAAY,EAAE;IACvC,OAAO,QAAQ,CAAC,SAAS,CAAC,CAAA;AAC5B,CAAC,CAAA"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier registry construction.
|
|
3
|
+
*
|
|
4
|
+
* `defineTiers(...)` builds a `TierRegistry` from a flat record of tiers plus
|
|
5
|
+
* a category upgrade order. Apps typically call this once at startup and
|
|
6
|
+
* bond the result via `setProvider(...)`.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
import type { DefineTiersOptions, TierRegistry } from './types.js';
|
|
11
|
+
/**
|
|
12
|
+
* Constructs a `TierRegistry` from a tier record and category order.
|
|
13
|
+
*
|
|
14
|
+
* Validates that the `defaultPlanKey` exists in the `tiers` record and that
|
|
15
|
+
* every tier's `category` appears in `categoryOrder`. Throws synchronously
|
|
16
|
+
* on misconfiguration so problems surface at startup, not at request time.
|
|
17
|
+
*
|
|
18
|
+
* @param options - The tier set, default plan key, and category upgrade order.
|
|
19
|
+
* @returns A typed tier registry suitable for `setProvider(...)`.
|
|
20
|
+
* @throws {Error} If `defaultPlanKey` is missing from `tiers` or any tier's
|
|
21
|
+
* category is missing from `categoryOrder`.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```typescript
|
|
25
|
+
* interface BlogLimits {
|
|
26
|
+
* maxPosts: number
|
|
27
|
+
* maxCommentsPerDay: number
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* const registry = defineTiers<BlogLimits>({
|
|
31
|
+
* tiers: {
|
|
32
|
+
* free: { planKey: 'free', category: 'free', name: 'Free', limits: { maxPosts: 5, maxCommentsPerDay: 50 } },
|
|
33
|
+
* stripeMonthly: { planKey: 'stripeMonthly', category: 'pro', name: 'Pro', limits: { maxPosts: 100, maxCommentsPerDay: 1000 } },
|
|
34
|
+
* },
|
|
35
|
+
* defaultPlanKey: 'free',
|
|
36
|
+
* categoryOrder: ['free', 'pro'],
|
|
37
|
+
* })
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export declare const defineTiers: <TLimits = unknown>(options: DefineTiersOptions<TLimits>) => TierRegistry<TLimits>;
|
|
41
|
+
//# sourceMappingURL=registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,kBAAkB,EAAQ,YAAY,EAAE,MAAM,YAAY,CAAA;AAExE;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,WAAW,GAAI,OAAO,GAAG,OAAO,EAC3C,SAAS,kBAAkB,CAAC,OAAO,CAAC,KACnC,YAAY,CAAC,OAAO,CAiEtB,CAAA"}
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier registry construction.
|
|
3
|
+
*
|
|
4
|
+
* `defineTiers(...)` builds a `TierRegistry` from a flat record of tiers plus
|
|
5
|
+
* a category upgrade order. Apps typically call this once at startup and
|
|
6
|
+
* bond the result via `setProvider(...)`.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
import { t } from '@molecule/api-i18n';
|
|
11
|
+
/**
|
|
12
|
+
* Constructs a `TierRegistry` from a tier record and category order.
|
|
13
|
+
*
|
|
14
|
+
* Validates that the `defaultPlanKey` exists in the `tiers` record and that
|
|
15
|
+
* every tier's `category` appears in `categoryOrder`. Throws synchronously
|
|
16
|
+
* on misconfiguration so problems surface at startup, not at request time.
|
|
17
|
+
*
|
|
18
|
+
* @param options - The tier set, default plan key, and category upgrade order.
|
|
19
|
+
* @returns A typed tier registry suitable for `setProvider(...)`.
|
|
20
|
+
* @throws {Error} If `defaultPlanKey` is missing from `tiers` or any tier's
|
|
21
|
+
* category is missing from `categoryOrder`.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```typescript
|
|
25
|
+
* interface BlogLimits {
|
|
26
|
+
* maxPosts: number
|
|
27
|
+
* maxCommentsPerDay: number
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* const registry = defineTiers<BlogLimits>({
|
|
31
|
+
* tiers: {
|
|
32
|
+
* free: { planKey: 'free', category: 'free', name: 'Free', limits: { maxPosts: 5, maxCommentsPerDay: 50 } },
|
|
33
|
+
* stripeMonthly: { planKey: 'stripeMonthly', category: 'pro', name: 'Pro', limits: { maxPosts: 100, maxCommentsPerDay: 1000 } },
|
|
34
|
+
* },
|
|
35
|
+
* defaultPlanKey: 'free',
|
|
36
|
+
* categoryOrder: ['free', 'pro'],
|
|
37
|
+
* })
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export const defineTiers = (options) => {
|
|
41
|
+
const { tiers, defaultPlanKey, categoryOrder } = options;
|
|
42
|
+
const defaultTier = tiers[defaultPlanKey];
|
|
43
|
+
if (!defaultTier) {
|
|
44
|
+
throw new Error(t('entitlements.error.missingDefaultTier', { planKey: defaultPlanKey }, {
|
|
45
|
+
defaultValue: `defineTiers: defaultPlanKey "${defaultPlanKey}" not found in tiers record.`,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
const orderRanks = new Map();
|
|
49
|
+
categoryOrder.forEach((category, index) => {
|
|
50
|
+
orderRanks.set(category, index);
|
|
51
|
+
});
|
|
52
|
+
for (const [planKey, tier] of Object.entries(tiers)) {
|
|
53
|
+
if (!orderRanks.has(tier.category)) {
|
|
54
|
+
throw new Error(t('entitlements.error.missingCategory', { planKey, category: tier.category }, {
|
|
55
|
+
defaultValue: `defineTiers: tier "${planKey}" has category "${tier.category}" which is not in categoryOrder.`,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const allTiers = Object.values(tiers);
|
|
60
|
+
return {
|
|
61
|
+
findTier(planKey) {
|
|
62
|
+
if (planKey == null)
|
|
63
|
+
return defaultTier;
|
|
64
|
+
const tier = tiers[planKey];
|
|
65
|
+
if (tier)
|
|
66
|
+
return tier;
|
|
67
|
+
return defaultTier;
|
|
68
|
+
},
|
|
69
|
+
getDefaultTier() {
|
|
70
|
+
return defaultTier;
|
|
71
|
+
},
|
|
72
|
+
getAllTiers() {
|
|
73
|
+
return allTiers;
|
|
74
|
+
},
|
|
75
|
+
getCategoryRank(category) {
|
|
76
|
+
const rank = orderRanks.get(category);
|
|
77
|
+
return rank ?? null;
|
|
78
|
+
},
|
|
79
|
+
getNextCategory(category) {
|
|
80
|
+
const rank = orderRanks.get(category);
|
|
81
|
+
if (rank == null)
|
|
82
|
+
return null;
|
|
83
|
+
const next = categoryOrder[rank + 1];
|
|
84
|
+
return next ?? null;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.js","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,oBAAoB,CAAA;AAItC;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CACzB,OAAoC,EACb,EAAE;IACzB,MAAM,EAAE,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,GAAG,OAAO,CAAA;IAExD,MAAM,WAAW,GAAG,KAAK,CAAC,cAAc,CAAC,CAAA;IACzC,IAAI,CAAC,WAAW,EAAE,CAAC;QACjB,MAAM,IAAI,KAAK,CACb,CAAC,CACC,uCAAuC,EACvC,EAAE,OAAO,EAAE,cAAc,EAAE,EAC3B;YACE,YAAY,EAAE,gCAAgC,cAAc,8BAA8B;SAC3F,CACF,CACF,CAAA;IACH,CAAC;IAED,MAAM,UAAU,GAAG,IAAI,GAAG,EAAkB,CAAA;IAC5C,aAAa,CAAC,OAAO,CAAC,CAAC,QAAQ,EAAE,KAAK,EAAE,EAAE;QACxC,UAAU,CAAC,GAAG,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAA;IACjC,CAAC,CAAC,CAAA;IAEF,KAAK,MAAM,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACpD,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;YACnC,MAAM,IAAI,KAAK,CACb,CAAC,CACC,oCAAoC,EACpC,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,EACpC;gBACE,YAAY,EAAE,sBAAsB,OAAO,mBAAmB,IAAI,CAAC,QAAQ,kCAAkC;aAC9G,CACF,CACF,CAAA;QACH,CAAC;IACH,CAAC;IAED,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC,KAAK,CAAoB,CAAA;IAExD,OAAO;QACL,QAAQ,CAAC,OAAO;YACd,IAAI,OAAO,IAAI,IAAI;gBAAE,OAAO,WAAW,CAAA;YACvC,MAAM,IAAI,GAAG,KAAK,CAAC,OAAO,CAAC,CAAA;YAC3B,IAAI,IAAI;gBAAE,OAAO,IAAI,CAAA;YACrB,OAAO,WAAW,CAAA;QACpB,CAAC;QAED,cAAc;YACZ,OAAO,WAAW,CAAA;QACpB,CAAC;QAED,WAAW;YACT,OAAO,QAAQ,CAAA;QACjB,CAAC;QAED,eAAe,CAAC,QAAQ;YACtB,MAAM,IAAI,GAAG,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;YACrC,OAAO,IAAI,IAAI,IAAI,CAAA;QACrB,CAAC;QAED,eAAe,CAAC,QAAQ;YACtB,MAAM,IAAI,GAAG,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;YACrC,IAAI,IAAI,IAAI,IAAI;gBAAE,OAAO,IAAI,CAAA;YAC7B,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,GAAG,CAAC,CAAC,CAAA;YACpC,OAAO,IAAI,IAAI,IAAI,CAAA;QACrB,CAAC;KACF,CAAA;AACH,CAAC,CAAA"}
|