diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 209909c..fd2b6ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [20, 22, 24] + node-version: [22, 24, 26] steps: - name: Checkout repo diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d39ccbf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# Important information for agents + +- When writing something intended for human consumption, (comment, commit message, reply to prompt) use as few words as possible. Pick every word meticulously to reduce the volume to a strict minimum. Be down to the point. Less is more. + +- Avoid superlatives and praise. Stop telling me I am absolutely right. Give me the cold hard truth. + +- Avoid magic numbers and strings by extracting recurring or meaningful values into descriptive constants (const) or enums. Keep self-explanatory, one-off values inline to avoid clutter. If a value comes from a spec (e.g. HTTP 200 OK), use a constant regardless. + +- Reduce code indentation. Avoid Arrow Anti-Pattern. Leverage early return and continue. + +- Keep function names short. Less than 30 characters. + +- Use enums instead of booleans for function parameters. + +- Let the reader of the code breathe. Add empty lines between logical blocks of code. + +- Add a small, to the point, comment to explain *what* the block does and *why*. Use examples when possible. Propose ASCII drawings to explain complete systems. + +- Treat member visibility changes as a breaking design shift. Keep all fields and functions private unless external access is strictly required by the design. Prompt the user for explicit approval before changing any access modifier from private to internal or public. + +- Program to levels of abstraction. Lower-level mechanics (e.g., raw hardware I/O, sector parsing, direct socket streams) must be encapsulated in a dedicated driver/abstraction layer. Expose clean, high-level APIs to the rest of the application so calling code works with domain concepts, not raw implementation details. + +- Don't touch blocks of code unrelated to the feature you implement. e.g. Don't add comments to a block of code if you did not create it or modify it. As much as possible try to minimize the number of changed lines when implementing a feature. + +- Strictly adhere to the layered boundary hierarchy: each layer may only communicate with its immediate neighbor directly below it. Never "punch holes" through layers (e.g., controllers or UI components must never directly call database queries, raw hardware drivers, or low-level network clients; always route through the intermediate service/abstraction layer). + +- Always use {}, even on a one-line "if" statement. + +When you write a commit message, follow these 7 rules: +Rule 1: Separate the subject line from the body with a single blank line. +Rule 2: Limit the subject line to 50 characters (72 is the absolute hard limit). +Rule 3: Capitalize the first letter of the subject line. +Rule 4: Do not end the subject line with a period. +Rule 5: Use the imperative mood in the subject line (e.g., "Fix bug," "Add feature," + not "Fixed" or "Adds"). Test formula: It must complete the sentence: "If applied, + this commit will [your subject line here]". +Rule 6: Wrap the body text manually at 72 characters to prevent Git formatting issues. +Rule 7: Use the body to explain what and why vs. how. Assume the code explains the how; + the message must explain the context and reasoning. + +- If the prompt indicates that a bug is being fixed, don't write the fix right away. First write the test. Observe it failing. Then write the fix. And observe the test passing. diff --git a/package.json b/package.json index 1aba9a8..ccb6dd6 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,10 @@ "private": true, "license": "MIT", "engines": { - "node": ">=20", - "pnpm": ">=10" + "node": ">=22", + "pnpm": ">=11" }, - "packageManager": "pnpm@10.18.1", + "packageManager": "pnpm@11.21.0", "devDependencies": { "@biomejs/biome": "^2.2.2", "@sindresorhus/tsconfig": "^7.0.0", diff --git a/packages/better-auth/CHANGELOG.md b/packages/better-auth/CHANGELOG.md index 2a07f65..2eb33e8 100644 --- a/packages/better-auth/CHANGELOG.md +++ b/packages/better-auth/CHANGELOG.md @@ -1,3 +1,15 @@ +### Unreleased +* * * + +### Feature: +- Added entitlements, backed by `@chargebee/entitlements` (optional peer dependency). Pass a `ChargebeeEntitlements` client as the new `entitlements` option. +- Added `auth.api.hasAccess` (`POST /entitlements/has-access`) for switch features and `auth.api.getEntitlements` (`GET /entitlements/list`), also on the client plugin. +- Added `entitlementsMiddleware(options)`, which puts `hasAccess` and `getEntitlements` on `ctx.context.entitlements` in Better Auth endpoints. +- Targets default to the user's or active organization's Chargebee customer. `subscriptionId` targets one subscription owned by the reference. +- Added the `read-entitlements` `authorizeReference` action, the `ENTITLEMENTS_NOT_CONFIGURED` error code, and the `EntitlementsAccess` and `BooleanFeature` types. + +--- + ### v1.2.0 (2026-07-10) * * * diff --git a/packages/better-auth/README.md b/packages/better-auth/README.md index 0bf55a5..11c3f74 100644 --- a/packages/better-auth/README.md +++ b/packages/better-auth/README.md @@ -17,6 +17,7 @@ The Chargebee plugin integrates [Chargebee's](https://www.chargebee.com) subscri - Team subscription support with seats management - Hosted checkout and portal via Chargebee Hosted Pages - Self-service billing portal for managing payment methods, invoices, and subscriptions +- Feature gating with Chargebee entitlements (`hasAccess`, `getEntitlements`) ## Requirements @@ -568,6 +569,71 @@ await authClient.subscription.cancel({ }); ``` +### Entitlements + +Gate features on the signed-in user's or organization's [Chargebee entitlements](https://www.chargebee.com/docs/billing/2.0/entitlements/entitlements), backed by [`@chargebee/entitlements`](https://github.com/chargebee/js-framework-adapters/blob/main/packages/entitlements/README.md) and its caching: + +```bash +pnpm add @chargebee/entitlements +``` + +```ts +import { ChargebeeEntitlements } from "@chargebee/entitlements/server" + +chargebee({ + chargebeeClient, + entitlements: new ChargebeeEntitlements({ chargebeeClient }), +}) +``` + +`hasAccess` takes switch features only, because other feature types need usage tracking to decide access. It returns `true` only when Chargebee grants the feature. Missing, disabled, and unavailable entitlements return the feature's default, which is `false` for a plain feature ID. `getEntitlements` returns every entitlement of the reference. + +**Server:** + +```ts +const { hasAccess } = await auth.api.hasAccess({ + headers, + body: { featureId: "advanced-reports" }, +}) +const entitlements = await auth.api.getEntitlements({ headers }) +``` + +**Client:** + +```ts +const { data } = await authClient.entitlements.hasAccess({ featureId: "advanced-reports" }) +const { data: entitlements } = await authClient.entitlements.list() +``` + +**Better Auth endpoints and plugins:** `entitlementsMiddleware` adds `ctx.context.entitlements` for the request: + +```ts +import { Feature } from "@chargebee/entitlements" +import { entitlementsMiddleware } from "@chargebee/better-auth" + +const reports = new Feature("advanced-reports", false) + +createAuthEndpoint("/reports", { method: "GET", use: [entitlementsMiddleware(chargebeeOptions)] }, async (ctx) => { + if (!(await ctx.context.entitlements.hasAccess(reports))) { + throw new APIError("FORBIDDEN") + } + return ctx.json(await ctx.context.entitlements.getEntitlements()) +}) +``` + +All three accept the same optional fields as the subscription routes: + +| Field | Target | +| ---------------- | ------------------------------------------------------------------------------------------------------------ | +| none | The user's Chargebee customer, consolidated across their subscriptions. | +| `customerType` | `"organization"` targets the active organization's customer. Requires `authorizeReference`. | +| `referenceId` | Another user or organization, authorized with `authorizeReference` and the `read-entitlements` action. | +| `subscriptionId` | That Chargebee subscription. Rejected with `403` unless it belongs to the reference. | + +A reference without a Chargebee customer has no entitlements. + +> **Note:** Subscription webhooks don't refresh entitlement snapshots yet, so plan changes take effect after `snapshotTtlMs` (5 minutes by default). To apply them sooner, call `entitlements.refreshSnapshot({ customerId })` from your own `webhookHandler`. + ### Webhook Handling The plugin automatically processes common webhook events from Chargebee: @@ -776,6 +842,7 @@ chargebee({ | `getCustomerCreateParams`| `function` | Return additional params for `cb.customer.create` (e.g. `first_name`, `last_name`). Receives `user` and optional `ctx`. | | `onCustomerCreate` | `function` | Callback called after a customer is created. Receives `{ chargebeeCustomer, user }`. | | `webhookHandler` | `function` | Callback receiving the webhook handler instance. Call `handler.on(EventType, fn)` to register typed event listeners. | +| `entitlements` | `ChargebeeEntitlements` | Entitlements client from `@chargebee/entitlements/server`. See [Entitlements](#entitlements). | | `subscription` | `object` | Subscription configuration. See [Subscription options](#subscription-options). | | `organization` | `object` | Enable Organization Customer support. See [Organization options](#organization-options). | diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index dd6bf5e..030a9f9 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -1,7 +1,7 @@ { "name": "@chargebee/better-auth", "author": "DX Chargebee", - "version": "1.2.0", + "version": "1.3.0", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", @@ -64,12 +64,19 @@ }, "peerDependencies": { "@better-auth/core": "^1.4.18", + "@chargebee/entitlements": "^0.1.0-alpha.2", "better-auth": "^1.4.0", "better-call": "^1.1.7", "chargebee": "^3.23.1" }, + "peerDependenciesMeta": { + "@chargebee/entitlements": { + "optional": true + } + }, "devDependencies": { "@better-auth/core": "^1.5.3", + "@chargebee/entitlements": "workspace:*", "@vitest/coverage-v8": "^4.0.18", "better-auth": "^1.5.3", "better-call": "^1.1.8", diff --git a/packages/better-auth/src/client.ts b/packages/better-auth/src/client.ts index 0846562..d2bfc27 100644 --- a/packages/better-auth/src/client.ts +++ b/packages/better-auth/src/client.ts @@ -37,6 +37,8 @@ export const chargebeeClient = < "/subscription/cancel": "POST", "/subscription/portal": "POST", "/subscription/list": "GET", + "/entitlements/has-access": "POST", + "/entitlements/list": "GET", }, } satisfies BetterAuthClientPlugin; diff --git a/packages/better-auth/src/entitlements.ts b/packages/better-auth/src/entitlements.ts new file mode 100644 index 0000000..a974a44 --- /dev/null +++ b/packages/better-auth/src/entitlements.ts @@ -0,0 +1,209 @@ +import type { ChargebeeTarget } from "@chargebee/entitlements"; +import type { ChargebeeEntitlements } from "@chargebee/entitlements/server"; +import type { GenericEndpointContext } from "better-auth"; +import { APIError, createAuthMiddleware } from "better-auth/api"; +import { CHARGEBEE_ERROR_CODES } from "./error-codes"; +import { referenceMiddleware, sessionMiddleware } from "./middleware"; +import type { + ChargebeeCtxSession, + ChargebeeOptions, + CustomerType, + EntitlementsAccess, + Subscription, + SubscriptionOptions, + WithChargebeeCustomerId, +} from "./types"; +import { getReferenceId } from "./utils"; + +type Adapter = GenericEndpointContext["context"]["adapter"]; + +/** `getSnapshot` throws it while a background refresh loads the first snapshot. */ +const SNAPSHOT_PENDING_ERROR = "SnapshotPendingError"; + +/** Without `authorizeReference`, only the session user's own reference passes. */ +const NO_SUBSCRIPTION_OPTIONS: SubscriptionOptions = { + enabled: false, + plans: [], +}; + +/** Table holding the Chargebee customer ID of each reference type. */ +const CUSTOMER_MODELS: Record = { + user: "user", + organization: "organization", +}; + +/** Request fields picking the billing reference, as on `/subscription/*`. */ +interface TargetInput { + customerType?: CustomerType; + referenceId?: string; + subscriptionId?: string; +} + +/** + * Maps the request's reference to a Chargebee target: + * + * subscriptionId given → { subscriptionId }, if the reference owns it + * otherwise → { customerId } of the user or organization + * no customer yet → undefined, so nothing is granted + */ +async function resolveTarget( + adapter: Adapter, + session: ChargebeeCtxSession, + input: TargetInput, + options: ChargebeeOptions, +): Promise { + const customerType = input.customerType ?? "user"; + const referenceId = + input.referenceId ?? getReferenceId(session, customerType, options); + + if (input.subscriptionId) { + return findSubscription(adapter, referenceId, input.subscriptionId); + } + + const customerId = await findCustomerId( + adapter, + session, + customerType, + referenceId, + ); + if (!customerId) { + return undefined; + } + + return { customerId }; +} + +/** A subscription target, only for a subscription the reference owns. */ +async function findSubscription( + adapter: Adapter, + referenceId: string, + subscriptionId: string, +): Promise { + const subscription = await adapter.findOne({ + model: "subscription", + where: [ + { field: "chargebeeSubscriptionId", value: subscriptionId }, + { field: "referenceId", value: referenceId }, + ], + }); + if (!subscription) { + throw new APIError("FORBIDDEN", { + message: CHARGEBEE_ERROR_CODES.UNAUTHORIZED_REFERENCE.message, + }); + } + + return { subscriptionId }; +} + +/** + * The session user usually carries its customer ID. Other references, and a + * session cached before the customer was created, are read from the database. + */ +async function findCustomerId( + adapter: Adapter, + session: ChargebeeCtxSession, + customerType: CustomerType, + referenceId: string, +): Promise { + const isSessionUser = + customerType === "user" && referenceId === session.user.id; + if (isSessionUser && session.user.chargebeeCustomerId) { + return session.user.chargebeeCustomerId; + } + + const record = await adapter.findOne({ + model: CUSTOMER_MODELS[customerType], + where: [{ field: "id", value: referenceId }], + }); + + return record?.chargebeeCustomerId ?? undefined; +} + +/** Resolves the target on first use, then reuses it for the whole request. */ +function createAccess( + client: ChargebeeEntitlements, + resolve: () => Promise, +): EntitlementsAccess { + let target: Promise | undefined; + const getTarget = () => { + target ??= resolve(); + return target; + }; + + return { + async hasAccess(feature) { + const { featureId, defaultValue } = + typeof feature === "string" + ? { featureId: feature, defaultValue: false } + : feature; + + const resolved = await getTarget(); + if (!resolved) { + return defaultValue; + } + + return client.get(featureId, defaultValue, resolved); + }, + + async getEntitlements() { + const resolved = await getTarget(); + if (!resolved) { + return []; + } + + try { + const { snapshot } = await client.getSnapshot(resolved); + return Object.values(snapshot.entitlements); + } catch (error) { + if (error instanceof Error && error.name === SNAPSHOT_PENDING_ERROR) { + return []; + } + throw error; + } + }, + }; +} + +/** + * Adds `ctx.context.entitlements` to Better Auth endpoints, for the session + * user or the reference picked by `customerType`, `referenceId`, and + * `subscriptionId`, authorized like the `/subscription/*` routes: + * + * ```ts + * createAuthEndpoint("/reports", { method: "GET", use: [entitlementsMiddleware(options)] }, + * async (ctx) => ctx.json({ allowed: await ctx.context.entitlements.hasAccess("reports") })); + * ``` + */ +export const entitlementsMiddleware = (options: ChargebeeOptions) => + createAuthMiddleware( + { + use: [ + sessionMiddleware, + referenceMiddleware( + options.subscription ?? NO_SUBSCRIPTION_OPTIONS, + "read-entitlements", + ), + ], + }, + async (ctx) => { + const client = options.entitlements; + if (!client) { + throw new APIError("BAD_REQUEST", { + message: CHARGEBEE_ERROR_CODES.ENTITLEMENTS_NOT_CONFIGURED.message, + }); + } + + const session = ctx.context.session as ChargebeeCtxSession; + const input: TargetInput = { + customerType: ctx.body?.customerType || ctx.query?.customerType, + referenceId: ctx.body?.referenceId || ctx.query?.referenceId, + subscriptionId: ctx.body?.subscriptionId || ctx.query?.subscriptionId, + }; + + const entitlements = createAccess(client, () => + resolveTarget(ctx.context.adapter, session, input, options), + ); + + return { entitlements }; + }, + ); diff --git a/packages/better-auth/src/error-codes.ts b/packages/better-auth/src/error-codes.ts index df9135c..ff5c30e 100644 --- a/packages/better-auth/src/error-codes.ts +++ b/packages/better-auth/src/error-codes.ts @@ -20,4 +20,6 @@ export const CHARGEBEE_ERROR_CODES = defineErrorCodes({ "Organization subscriptions require authorizeReference callback to be configured", ORGANIZATION_REFERENCE_ID_REQUIRED: "Reference ID is required. Provide referenceId or set activeOrganizationId in session", + ENTITLEMENTS_NOT_CONFIGURED: + "Entitlements require the entitlements option in your chargebee plugin config", }); diff --git a/packages/better-auth/src/index.ts b/packages/better-auth/src/index.ts index 5a6454e..22463e7 100644 --- a/packages/better-auth/src/index.ts +++ b/packages/better-auth/src/index.ts @@ -7,7 +7,9 @@ import { cancelSubscriptionCallback, createPortalSession, createSubscription, + getEntitlements, getWebhookEndpoint, + hasAccess, listActiveSubscriptions, subscriptionSuccess, updateSubscription, @@ -44,6 +46,8 @@ export const chargebee = (options: O) => { cancelSubscriptionCallback: cancelSubscriptionCallback(options), createPortalSession: createPortalSession(options), listActiveSubscriptions: listActiveSubscriptions(options), + hasAccess: hasAccess(options), + getEntitlements: getEntitlements(options), }, options: options as NoInfer, $ERROR_CODES: CHARGEBEE_ERROR_CODES, @@ -208,11 +212,14 @@ export type ChargebeePlugin = ReturnType< // Re-export native Better Auth types for convenience export type { Organization } from "better-auth/plugins/organization"; +export { entitlementsMiddleware } from "./entitlements"; export { CHARGEBEE_ERROR_CODES } from "./error-codes"; export type { + BooleanFeature, ChargebeeOptions, ChargebeePlan, ChargebeeWebhookEventBus, + EntitlementsAccess, Subscription, SubscriptionOptions, SubscriptionStatus, diff --git a/packages/better-auth/src/routes.ts b/packages/better-auth/src/routes.ts index 315a8f9..5b92ae5 100644 --- a/packages/better-auth/src/routes.ts +++ b/packages/better-auth/src/routes.ts @@ -6,6 +6,7 @@ import { } from "better-auth/api"; import type { Organization } from "better-auth/plugins/organization"; import { z } from "zod"; +import { entitlementsMiddleware } from "./entitlements"; import { CHARGEBEE_ERROR_CODES } from "./error-codes"; import { referenceMiddleware, sessionMiddleware } from "./middleware"; import type { @@ -1250,3 +1251,55 @@ export function cancelSubscription(options: ChargebeeOptions) { }, ); } + +/** Fields picking the billing reference, shared by the entitlement endpoints. */ +const entitlementTargetSchema = z.object({ + referenceId: z.string().optional(), + subscriptionId: z.string().optional(), + customerType: z.enum(["user", "organization"]).optional(), +}); + +/** + * Whether the reference is granted a switch feature. Missing, disabled, and + * unavailable entitlements deny access. + */ +export function hasAccess(options: ChargebeeOptions) { + return createAuthEndpoint( + "/entitlements/has-access", + { + method: "POST", + body: entitlementTargetSchema.extend({ featureId: z.string().min(1) }), + metadata: { + openapi: { + operationId: "hasAccess", + }, + }, + use: [entitlementsMiddleware(options)], + }, + async (ctx) => { + const granted = await ctx.context.entitlements.hasAccess( + ctx.body.featureId, + ); + + return ctx.json({ hasAccess: granted }); + }, + ); +} + +/** Every entitlement of the reference; empty without a Chargebee customer. */ +export function getEntitlements(options: ChargebeeOptions) { + return createAuthEndpoint( + "/entitlements/list", + { + method: "GET", + query: entitlementTargetSchema.optional(), + metadata: { + openapi: { + operationId: "getEntitlements", + }, + }, + use: [entitlementsMiddleware(options)], + }, + async (ctx) => ctx.json(await ctx.context.entitlements.getEntitlements()), + ); +} diff --git a/packages/better-auth/src/types.ts b/packages/better-auth/src/types.ts index 2b2e1cd..a764075 100644 --- a/packages/better-auth/src/types.ts +++ b/packages/better-auth/src/types.ts @@ -1,3 +1,9 @@ +import type { + ChargebeeEntitlement, + EntitlementsClient, + Feature, +} from "@chargebee/entitlements"; +import type { ChargebeeEntitlements } from "@chargebee/entitlements/server"; import type { Session, User } from "better-auth"; import type { Organization } from "better-auth/plugins/organization"; import type Chargebee from "chargebee"; @@ -40,7 +46,8 @@ export type AuthorizeReferenceAction = | "list-subscription" | "cancel-subscription" | "restore-subscription" - | "billing-portal"; + | "billing-portal" + | "read-entitlements"; export type WithActiveOrganizationId = { activeOrganizationId?: string; @@ -185,6 +192,12 @@ export interface ChargebeeOptions { * When not set, events are processed synchronously within the request. */ webhookEventBus?: ChargebeeWebhookEventBus; + /** + * Entitlements client backing `hasAccess` and `getEntitlements`, e.g. + * `new ChargebeeEntitlements({ chargebeeClient })` from + * `@chargebee/entitlements/server`. + */ + entitlements?: ChargebeeEntitlements; subscription?: SubscriptionOptions; organization?: { enabled: boolean; @@ -243,3 +256,17 @@ export interface SubscriptionItemRecord { export type WithChargebeeCustomerId = { chargebeeCustomerId?: string; }; + +/** A switch feature: the only kind that resolves to access without usage data. */ +export type BooleanFeature = Feature; + +/** + * Entitlements of the request's billing reference, e.g. + * `await ctx.context.entitlements.hasAccess("advanced-reports")`. + */ +export interface EntitlementsAccess { + /** `true` only when Chargebee grants the feature; otherwise its default. */ + hasAccess(feature: string | BooleanFeature): Promise; + /** Every entitlement of the reference; empty without a Chargebee customer. */ + getEntitlements(): Promise; +} diff --git a/packages/better-auth/src/version.ts b/packages/better-auth/src/version.ts index a3a25fb..e853866 100644 --- a/packages/better-auth/src/version.ts +++ b/packages/better-auth/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.2.0"; +export const VERSION = "1.3.0"; diff --git a/packages/better-auth/test/entitlements.test.ts b/packages/better-auth/test/entitlements.test.ts new file mode 100644 index 0000000..d415563 --- /dev/null +++ b/packages/better-auth/test/entitlements.test.ts @@ -0,0 +1,292 @@ +import { Feature } from "@chargebee/entitlements"; +import { ChargebeeEntitlements } from "@chargebee/entitlements/server"; +import type { BetterAuthPlugin } from "better-auth"; +import { createAuthEndpoint } from "better-auth/api"; +import { organization } from "better-auth/plugins"; +import { getTestInstance } from "better-auth/test"; +import type Chargebee from "chargebee"; +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { chargebee, entitlementsMiddleware } from "../src"; +import { CHARGEBEE_ERROR_CODES } from "../src/error-codes"; +import type { ChargebeeOptions } from "../src/types"; + +const USER_CUSTOMER_ID = "cus_user"; +const ORG_CUSTOMER_ID = "cus_org"; +const SUBSCRIPTION_ID = "sub_1"; + +/** Chargebee answering every customer with `sso` and `seats`, every subscription with `reports`. */ +function makeChargebee() { + const customerRequest = vi.fn(async () => ({ + list: [ + { + customer_entitlement: { + feature_id: "sso", + value: "true", + is_enabled: true, + }, + }, + { + customer_entitlement: { + feature_id: "seats", + value: "10", + is_enabled: true, + }, + }, + ], + })); + const subscriptionRequest = vi.fn(async () => ({ + list: [ + { + subscription_entitlement: { + feature_id: "reports", + value: "true", + is_enabled: true, + }, + }, + ], + })); + const chargebeeClient = { + __clientIdentifier: () => {}, + customerEntitlement: { entitlementsForCustomer: customerRequest }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: subscriptionRequest, + }, + } as unknown as Chargebee; + + return { chargebeeClient, customerRequest, subscriptionRequest }; +} + +/** Test-only endpoint exercising the middleware the way an app would. */ +function probePlugin(options: ChargebeeOptions) { + return { + id: "probe", + endpoints: { + probe: createAuthEndpoint( + "/probe", + { + method: "GET", + query: z.object({ subscriptionId: z.string().optional() }), + use: [entitlementsMiddleware(options)], + }, + async (ctx) => { + const { entitlements } = ctx.context; + const premium = new Feature("premium", true); + + return ctx.json({ + premium: await entitlements.hasAccess(premium), + reports: await entitlements.hasAccess("reports"), + entitlements: await entitlements.getEntitlements(), + }); + }, + ), + }, + } satisfies BetterAuthPlugin; +} + +async function setup(overrides: Partial = {}) { + const chargebeeMock = makeChargebee(); + const options: ChargebeeOptions = { + chargebeeClient: chargebeeMock.chargebeeClient, + entitlements: new ChargebeeEntitlements({ + chargebeeClient: chargebeeMock.chargebeeClient, + }), + subscription: { + enabled: true, + plans: [], + authorizeReference: async () => true, + }, + ...overrides, + }; + + const plugins = [chargebee(options), probePlugin(options)]; + const { auth, signInWithTestUser } = await getTestInstance({ + plugins: options.organization?.enabled + ? [organization(), ...plugins] + : plugins, + }); + const { headers, user } = await signInWithTestUser(); + const context = await auth.$context; + + return { auth, headers, user, context, ...chargebeeMock }; +} + +describe("entitlements - customer target", () => { + it("evaluates the session user's customer", async () => { + const { auth, headers, user, context, customerRequest } = await setup(); + await context.internalAdapter.updateUser(user.id, { + chargebeeCustomerId: USER_CUSTOMER_ID, + }); + + const result = await auth.api.hasAccess({ + headers, + body: { featureId: "sso" }, + }); + + expect(result).toEqual({ hasAccess: true }); + expect(customerRequest).toHaveBeenCalledWith( + USER_CUSTOMER_ID, + expect.anything(), + ); + }); + + it("lists the customer's entitlements", async () => { + const { auth, headers, user, context } = await setup(); + await context.internalAdapter.updateUser(user.id, { + chargebeeCustomerId: USER_CUSTOMER_ID, + }); + + const entitlements = await auth.api.getEntitlements({ headers }); + + expect(entitlements.map((entitlement) => entitlement.featureId)).toEqual([ + "sso", + "seats", + ]); + }); + + it("evaluates the active organization's customer", async () => { + const { auth, headers, context, customerRequest } = await setup({ + organization: { enabled: true }, + }); + const org = await auth.api.createOrganization({ + headers, + body: { name: "Acme", slug: "acme" }, + }); + await context.adapter.update({ + model: "organization", + where: [{ field: "id", value: org?.id ?? "" }], + update: { chargebeeCustomerId: ORG_CUSTOMER_ID }, + }); + + const result = await auth.api.hasAccess({ + headers, + body: { featureId: "sso", customerType: "organization" }, + }); + + expect(result).toEqual({ hasAccess: true }); + expect(customerRequest).toHaveBeenCalledWith( + ORG_CUSTOMER_ID, + expect.anything(), + ); + }); + + it("denies access without a Chargebee customer", async () => { + const { auth, headers, customerRequest } = await setup(); + + const result = await auth.api.hasAccess({ + headers, + body: { featureId: "sso" }, + }); + const entitlements = await auth.api.getEntitlements({ headers }); + + expect(result).toEqual({ hasAccess: false }); + expect(entitlements).toEqual([]); + expect(customerRequest).not.toHaveBeenCalled(); + }); +}); + +describe("entitlements - subscription target", () => { + it("evaluates a subscription the reference owns", async () => { + const { auth, headers, user, context, subscriptionRequest } = + await setup(); + await context.adapter.create({ + model: "subscription", + data: { + referenceId: user.id, + chargebeeSubscriptionId: SUBSCRIPTION_ID, + status: "active", + }, + }); + + const result = await auth.api.hasAccess({ + headers, + body: { featureId: "reports", subscriptionId: SUBSCRIPTION_ID }, + }); + + expect(result).toEqual({ hasAccess: true }); + expect(subscriptionRequest).toHaveBeenCalledWith( + SUBSCRIPTION_ID, + expect.anything(), + ); + }); + + it("rejects a subscription of another reference", async () => { + const { auth, headers, context, subscriptionRequest } = await setup(); + await context.adapter.create({ + model: "subscription", + data: { + referenceId: "someone-else", + chargebeeSubscriptionId: SUBSCRIPTION_ID, + status: "active", + }, + }); + + await expect( + auth.api.hasAccess({ + headers, + body: { featureId: "reports", subscriptionId: SUBSCRIPTION_ID }, + }), + ).rejects.toMatchObject({ status: "FORBIDDEN" }); + expect(subscriptionRequest).not.toHaveBeenCalled(); + }); +}); + +describe("entitlements - middleware", () => { + it("uses a boolean feature's own default", async () => { + const { auth, headers } = await setup(); + + const result = await auth.api.probe({ headers, query: {} }); + + expect(result).toEqual({ + premium: true, + reports: false, + entitlements: [], + }); + }); + + it("resolves the target once per request", async () => { + const { auth, headers, user, context } = await setup(); + await context.adapter.create({ + model: "subscription", + data: { + referenceId: user.id, + chargebeeSubscriptionId: SUBSCRIPTION_ID, + status: "active", + }, + }); + const findOne = vi.spyOn(context.adapter, "findOne"); + + const result = await auth.api.probe({ + headers, + query: { subscriptionId: SUBSCRIPTION_ID }, + }); + + const subscriptionLookups = findOne.mock.calls.filter( + ([query]) => query.model === "subscription", + ); + expect(subscriptionLookups).toHaveLength(1); + expect(result.reports).toBe(true); + }); + + it("requires the entitlements option", async () => { + const { auth, headers } = await setup({ entitlements: undefined }); + + await expect( + auth.api.hasAccess({ headers, body: { featureId: "sso" } }), + ).rejects.toMatchObject({ + message: CHARGEBEE_ERROR_CODES.ENTITLEMENTS_NOT_CONFIGURED.message, + }); + }); +}); + +/** Compile-time only: non-boolean features have no access semantics. */ +function _rejectsNonBooleanFeatures(options: ChargebeeOptions) { + createAuthEndpoint( + "/typed", + { method: "GET", use: [entitlementsMiddleware(options)] }, + async (ctx) => { + // @ts-expect-error a number feature is not a switch + await ctx.context.entitlements.hasAccess(new Feature("seats", 0)); + }, + ); +} diff --git a/packages/entitlements/CHANGELOG.md b/packages/entitlements/CHANGELOG.md new file mode 100644 index 0000000..dc4f7c7 --- /dev/null +++ b/packages/entitlements/CHANGELOG.md @@ -0,0 +1,91 @@ +# Changelog + +## Unreleased + +- The web client calls `fetch` unbound. Browsers' native `fetch` threw + "Illegal invocation", so no relay snapshot could load. +- No OpenFeature vocabulary remains in this package; `@chargebee/openfeature` + maps the results instead. `EntitlementResolution` is now + `EntitlementDetails`: `{ value, status, source?, entitlement?, error? }`. + `status` is `granted`, `disabled`, `pending`, `stale`, or `error`; + `error.code` is `not-found`, `type-mismatch`, `invalid-target`, or + `unavailable`. `reason`, `variant`, `flagMetadata`, `errorCode`, and + `errorMessage` are removed, along with the OpenFeature error codes. +- Both clients and `Feature` share one method pair: `get` returns the value, + `getDetails` the `EntitlementDetails`. `getValue` is removed. +- `ChargebeeEntitlementsWebClient` is now `ChargebeeEntitlements` from + `/web` (options: `ChargebeeEntitlementsOptions`), mirroring the server + client. Callbacks match the server's names: `onStale` is + `onSnapshotExpired`, `onConfigurationChanged(flagsChanged)` is + `onSnapshotRefreshed({ snapshot, changedFeatureIds })`, and + `onError(message)` is `onError(error, { operation })`. +- Added `entitlements.for(target)` on the server client. It returns a + `ScopedEntitlements` with the browser client's shape (`get`, `getDetails`, + `feature`, no target) that shares the parent's cache and store. +- `EntitlementsClient` is now `TargetedEntitlementsClient` (server, target + required) or `ScopedEntitlementsClient` (browser client or + `entitlements.for(target)`, no target). `Feature` takes the client type as + a second type parameter, so a server client's `feature(...)` requires + `get(target)` and a scoped client's requires `get()`. A standalone feature + still accepts either; a missing target on the server returns + `invalid-target`. +- The relay stamps `expiresAt` as now plus `relayTtlMs`, even when the server + serves an expired stored snapshot. Browsers no longer refetch on every + evaluation during a Chargebee outage. +- The web client discards relay responses started before `reset()` or + `close()`, and reports a still-expired snapshot through + `onSnapshotExpired` once. +- The package no longer ships `dist/src`, `dist/test`, or `.tsbuildinfo`. +- A feature is declared with an ID and a mandatory, concrete default value: + `new Feature("licensed-seats", 0)` (or `entitlements.feature(...)`, + which binds it to a client). `feature.get(target)` returns the typed value + and `feature.getDetails(target)` the full resolution. Standalone features + resolve against the client passed to `setDefaultEntitlements(client)`. + Because the default value is always concrete, its runtime type is the single + source for how an entitlement is parsed — there is no `type` option, no + inference from Chargebee's `featureType`, and no `FeatureValueType`. + Function-valued defaults are gone with them; await `get` and branch on the + result if a fallback needs to come from a database. +- A target is `{ customerId }` or `{ subscriptionId }`. The `mode` field, + `ChargebeeEvaluationMode`, the `defaultMode` option, and the + `chargebeeCustomerId`/`chargebeeSubscriptionId`/`chargebeeEvaluationMode` + context keys (with `CHARGEBEE_CONTEXT_KEYS` and `getTargetFromContext`) are + removed. Passing both identifiers, or neither, is rejected rather than + resolved by precedence. Other properties on the object are ignored, so a + request context can be passed straight through. +- `getValue(featureId, defaultValue, target)` is the only evaluation method on + both clients; `getBooleanValue`, `getStringValue`, `getNumberValue`, and + `getObjectValue` are removed, as are the standalone + `resolveBooleanEntitlement`/`resolveString...`/`resolveNumber...`/`resolveObject...` + helpers. +- `logger` moved from every method signature to `ChargebeeEntitlementsOptions`. +- Snapshots no longer carry `targetMode`; `createEntitlementsSnapshot` takes + `(entitlements, ttlMs, now?)` and now lives on `/server`, alongside the + `writeSnapshot` call that consumes it. `serializeEntitlementsSnapshot` and + `parseSerializedEntitlementsSnapshot` moved to `/cache`, next to the + `EntitlementsStorage` interface that needs them. +- Trimmed the export surface substantially. The root entry point is now + `Feature`, `setDefaultEntitlements`, and the five types that appear in their + signatures; `Feature` has exactly one import path instead of being + re-exported from `/server` as well. Removed `defineFeatures`, + `getDefaultEntitlements`, `toResolutionDetails` (now internal to + `@chargebee/openfeature`), the `advanced.resolveTarget` option, and the + `FeatureOptions`, `FeatureGetOptions`, `FeatureDefaultValue`, + `FeatureDefaultResolver`, `FeatureDefaultContext`, `FeatureDefinition`, + `FeatureCatalog`, `FeatureTarget`, `EntitlementsEvaluator`, + `EvaluationContextLike`, `RefreshOnMiss`, `SnapshotOperation`, + `EntitlementsSnapshotResult`, `EntitlementsRelayHandler`, + `EntitlementsRelaySource`, and `ChargebeeEntitlementsClient` types. +- Replaced `zod` dependency in snapshot parsing with zero-dependency lightweight validation. +- Deduplicated internal evaluation, storage, and loader normalization logic. +- Initial extraction of the framework-agnostic Chargebee entitlements client + from `@chargebee/openfeature`. `ChargebeeEntitlements` (server) and + `ChargebeeEntitlementsWebClient` (web) can now be used directly, without an + OpenFeature SDK. `@chargebee/openfeature` is now a thin adapter over this + package. +- The shared snapshot/cache/relay modules (`shared`, `cache`, `server`, + `web`, `nextjs`) moved here unchanged from `@chargebee/openfeature`. See + that package's changelog for their history prior to the split. +- The default cache-key namespace changed from `chargebee:openfeature:v1` to + `chargebee:entitlements:v1`. Pass `cacheNamespace` explicitly if you need to + keep reading previously cached keys. diff --git a/packages/entitlements/LICENSE.md b/packages/entitlements/LICENSE.md new file mode 100644 index 0000000..fce48db --- /dev/null +++ b/packages/entitlements/LICENSE.md @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2025 - present, Chargebee + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/packages/entitlements/README.md b/packages/entitlements/README.md new file mode 100644 index 0000000..33eff31 --- /dev/null +++ b/packages/entitlements/README.md @@ -0,0 +1,206 @@ +# Chargebee Entitlements + +Package `@chargebee/entitlements` makes it easy to gate app features by evaluating them against Chargebee's entitlements. It fetches a customer's or subscription's entitlements once, caches them in Redis or in memory, keeps a durable snapshot in your database, and refreshes it in the background. + +You can use it on the server or on the browser with minimal configuration. We have an [adapter](./src/nextjs.ts) for Next.js apps as well. + +```sh +pnpm add @chargebee/entitlements chargebee +``` + +## Quick start + +```ts +import Chargebee from "chargebee"; +import { ChargebeeEntitlements } from "@chargebee/entitlements/server"; + +const chargebee = new Chargebee({ + site: process.env.CHARGEBEE_SITE!, + apiKey: process.env.CHARGEBEE_API_KEY!, +}); + +export const entitlements = new ChargebeeEntitlements({ + chargebeeClient: chargebee, +}); + +const seats = entitlements.feature("licensed-seats", 0); // Feature +const count = await seats.get({ customerId: user.chargebeeCustomerId }); +``` + +## Features + +A feature is an ID and a default value. Chargebee stores every value as a string, and the default's type decides how it is read: + +| Default value | Chargebee value | Resolved as | +| --- | --- | --- | +| `boolean` | Switch (`true`, `false`, `available`, or a bare grant) | Boolean | +| `string` | Any | The raw string | +| `number` | Quantity or range; `unlimited` | Number; `Number.POSITIVE_INFINITY` | +| object | Any | The full sanitized entitlement | + +Features can be bound to a client, as show above, or declared once and shared between server and browser code. Standalone features use the client registered with `setDefaultEntitlements`: + +```ts +// features.ts +import { Feature, setDefaultEntitlements } from "@chargebee/entitlements"; + +export const seats = new Feature("licensed-seats", 0); +export const reports = new Feature("advanced-reports", false); + +// at start-up +setDefaultEntitlements(entitlements); +``` + +`get` returns the value. `getDetails` also says why: + +```ts +const { value, status, source, entitlement, error } = await seats.getDetails(target); +``` + +| `status` | Meaning | `value` | +| --- | --- | --- | +| `granted` | Enabled and read as the declared type | Chargebee's value | +| `disabled` | Disabled or expired | Default | +| `pending` | No snapshot loaded yet | Default | +| `stale` | Browser snapshot expired and refreshing | Default | +| `error` | `error.code` is `not-found`, `type-mismatch`, `invalid-target`, or `unavailable` | Default | + +`source` is `api`, `cache`, `store`, or `relay`, and `entitlement` is the Chargebee entitlement behind the value. + +## Targets + +A target is `{ customerId }`, whose entitlements are consolidated across the customer's subscriptions, or `{ subscriptionId }`. Passing both or neither resolves to `invalid-target`, so the package never guesses which one you meant. Other properties are ignored, so a request context can be passed straight through. + +The server client evaluates a feature ID directly, or binds a target once per request with `for`: + +```ts +await entitlements.get("licensed-seats", 0, { customerId }); + +const scoped = entitlements.for({ customerId }); +await scoped.get("licensed-seats", 0); +await scoped.feature("advanced-reports", false).get(); +``` + +A bound feature's `get` requires a target on the server client and takes none on a scoped or browser client. Getting this wrong fails to compile. + +## Caching and storage + +The client loads the complete snapshot for a target, so evaluating several features makes one Chargebee call. It looks for the snapshot in this order: + +1. `cache`: fast storage that absorbs the many checks one request makes. +2. `durableStore`: the source of truth, such as a PostgreSQL table. +3. Chargebee, only when the store is empty. The result is written back to both. + +Both slots take the same `EntitlementsStorage` interface (`get`, `set`, `delete`). With neither configured, every miss goes to Chargebee. + +```ts +import { + createMemoryEntitlementsCache, + createRedisEntitlementsCache, +} from "@chargebee/entitlements/cache"; +import Redis from "ioredis"; + +// Redis when available (pnpm add ioredis), otherwise in-process memory +const cache = process.env.REDIS_URL + ? createRedisEntitlementsCache(new Redis(process.env.REDIS_URL)) + : createMemoryEntitlementsCache({ maxEntries: 500 }); + +export const entitlements = new ChargebeeEntitlements({ + chargebeeClient: chargebee, + cache, + durableStore: postgresSnapshotStore, + snapshotTtlMs: 24 * 60 * 60_000, +}); +``` + +The Redis cache takes an [ioredis](https://github.com/redis/ioredis) client, a `Cluster`, or a custom implementation that satisfies the `RedisEntitlementsCacheClient` interface. + +The memory cache is a simple in-process LRU cache which holds `maxEntries` (500 by default) snapshots by default before eviction. Each process keeps its own copy, and a webhook refresh only clears the copy in the process that handled it. Due to inconsistencies this may cause, it's strongly suggested that production deployments use the Redis cache instead. + +Both caches expire entries after `ttlMs` (60 seconds by default), and the client's `cacheTtlMs` overrides that per write. `snapshotTtlMs` (5 minutes by default) decides when a snapshot is refreshed from Chargebee. Don't delete store rows at `expiresAt`: the client serves an expired snapshot while it refreshes in the background, which keeps entitlements available during a +Chargebee outage. + +Cache and store read failures fall through to the next step and are reported through `onError`. Concurrent refreshes for one target are deduplicated, and a failed refresh waits `advanced.refreshBackoffMs` (10 seconds) before retrying. + +## Reading from the API + +When required, the `getFromAPI` method can be used to skip the cache and store, and fetch the entitlement directly from the Chargebee API. + +```ts +await entitlements.getFromAPI("licensed-seats", 0, { customerId }); +``` + +The fetched snapshot is written to the store and the cache entry is evicted, so later reads pick it up. Each call makes its own Chargebee request and replaces any refresh already in flight, so keep it off hot paths. When Chargebee fails, it returns the default instead of a cached value. + +## Webhooks + +```ts +await entitlements.refreshSnapshot({ subscriptionId }); +await entitlements.deleteSnapshot({ customerId }); +``` + +`refreshSnapshot` drops the cached copy before calling Chargebee, so reads fall through to the store until the fresh snapshot lands. It also replaces any refresh a request started earlier. `deleteSnapshot` clears the cache and the store. To save a snapshot you built yourself, for example from a webhook payload, pass `createEntitlementsSnapshot(...)` to `writeSnapshot`. + +## Background refresh + +With `refreshOnMiss: "background"`, requests never wait on Chargebee. When no snapshot exists yet, evaluations return the default with status `pending` while the client fetches one, and `onSnapshotRefreshed` fires when it lands: + +```ts +new ChargebeeEntitlements({ + chargebeeClient: chargebee, + refreshOnMiss: "background", + onSnapshotRefreshed: ({ target, trigger }) => { + if (trigger === "request") notifySubscriptionReady(target); + }, + onError: (error, { operation, target }) => logger.error({ error, operation, target }), +}); +``` + +Pending evaluations return the default values, so set them to your lowest paid tier. On platforms that freeze the process after the response, refresh from a webhook worker or a scheduled job instead. + +## Browser + +The Chargebee API key is secret, so the browser loads a sanitized snapshot from a relay route in your app: + +```ts +// app/api/entitlements/route.ts +import { createEntitlementsRelayHandler } from "@chargebee/entitlements/nextjs"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export const GET = createEntitlementsRelayHandler({ + entitlements, + resolveContext: async (request) => { + const session = await getSession(request); + return session?.user.chargebeeCustomerId + ? { customerId: session.user.chargebeeCustomerId } + : null; // 401 + }, +}); +``` + +`resolveContext` must take the billing identity from the authenticated session. The relay rejects `customerId` and `subscriptionId` query parameters, responds with `private, no-store`, and never returns IDs or credentials. Outside Next.js, use the request-generic `createEntitlementsRelayHandler` from `/server`. + +The browser client has the server client's name and methods, without the target, and evaluates synchronously: + +```ts +import { ChargebeeEntitlements } from "@chargebee/entitlements/web"; + +const entitlements = new ChargebeeEntitlements({ relayUrl: "/api/entitlements" }); +await entitlements.initialize(); +setDefaultEntitlements(entitlements); + +entitlements.get("advanced-reports", false); +await seats.get(); // shared features take no target +``` + +Before the first load, evaluations return `pending`. Expired snapshots return defaults with status `stale` while the client refetches. Observe this with `onSnapshotExpired`, `onSnapshotRefreshed` (which lists `changedFeatureIds`), and `onError`. Call `reset()` when the signed-in billing subject changes and `close()` on teardown. For subscription-scoped access, pick and authorize the subscription in `resolveContext`, with one relay URL per subscription. + +## Notes + +- Customer targets use `consolidate_entitlements=true` by default. +- Without a `durableStore`, an expired snapshot is refetched before the evaluation resolves, as long as the cache `ttlMs` is shorter than `snapshotTtlMs`. +- Node.js is the supported Next.js runtime. +- Call `entitlements.close()` when a long-lived process shuts down. +- For OpenFeature SDKs, use [`@chargebee/openfeature`](https://github.com/chargebee/js-framework-adapters/blob/main/packages/openfeature/README.md). It shares this package's caching and only maps `status` and `error.code` onto OpenFeature reasons and error codes. diff --git a/packages/entitlements/bench/docker-compose.yml b/packages/entitlements/bench/docker-compose.yml new file mode 100644 index 0000000..89070d0 --- /dev/null +++ b/packages/entitlements/bench/docker-compose.yml @@ -0,0 +1,41 @@ +name: entitlements-bench + +# Toxiproxy sits between the benchmarks and each service, so network latency +# is injected per service (REDIS_DELAY_MS, POSTGRES_DELAY_MS): +# +# vitest bench ──► toxiproxy :16379 ──► redis :6379 +# └─► toxiproxy :15432 ──► postgres :5432 +# └─► toxiproxy :8474 (control API) +services: + redis: + image: redis:8-alpine + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + retries: 30 + + postgres: + image: postgres:17-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: entitlements + healthcheck: + test: ["CMD", "pg_isready", "-h", "localhost", "-U", "postgres", "-d", "entitlements"] + interval: 2s + retries: 30 + + toxiproxy: + image: ghcr.io/shopify/toxiproxy:2.12.0 + command: ["-host", "0.0.0.0", "-config", "/config/toxiproxy.json"] + volumes: + - ./toxiproxy.json:/config/toxiproxy.json:ro + ports: + - "8474:8474" + - "16379:16379" + - "15432:15432" + depends_on: + redis: + condition: service_healthy + postgres: + condition: service_healthy diff --git a/packages/entitlements/bench/entitlements.bench.ts b/packages/entitlements/bench/entitlements.bench.ts new file mode 100644 index 0000000..0a1af16 --- /dev/null +++ b/packages/entitlements/bench/entitlements.bench.ts @@ -0,0 +1,165 @@ +import { afterAll, beforeAll, test } from "vitest"; +import type { + ChargebeeTarget, + EntitlementDetails, + EntitlementStatus, + SnapshotSource, +} from "../src/shared"; +import { + CHARGEBEE_DELAY_MS, + POSTGRES_DELAY_MS, + REDIS_DELAY_MS, +} from "./support/config"; +import { BenchStack } from "./support/stack"; + +const FEATURE_ID = "licensed-seats"; +const DEFAULT_SEATS = 0; + +// Each scenario owns a target, so interleaved iterations can't disturb each other. +const CACHE_HIT: ChargebeeTarget = { customerId: "cust_cache_hit" }; +const STORE_HIT: ChargebeeTarget = { customerId: "cust_store_hit" }; +const BLOCKING_MISS: ChargebeeTarget = { customerId: "cust_blocking_miss" }; +const BACKGROUND_MISS: ChargebeeTarget = { customerId: "cust_background_miss" }; +const FROM_API: ChargebeeTarget = { customerId: "cust_from_api" }; + +// `bench.compare` interleaves iterations, so a time budget would make fast +// scenarios wait on slow ones. Fixed counts keep runs bounded instead. +const STORED_RUN = { + time: 0, + iterations: 200, + warmupTime: 0, + warmupIterations: 20, +}; +const MISSING_RUN = { + time: 0, + iterations: 20, + warmupTime: 0, + warmupIterations: 3, +}; +const TIMEOUT_MS = 5 * 60 * 1000; + +const LATENCY = `redis +${REDIS_DELAY_MS}ms, postgres +${POSTGRES_DELAY_MS}ms, chargebee ${CHARGEBEE_DELAY_MS}ms`; + +let stack: BenchStack; + +beforeAll(async () => { + stack = await BenchStack.start(); +}); + +afterAll(async () => { + await stack?.close(); +}); + +/** Fails the run when a scenario took a different path than it claims to measure. */ +function expectPath( + details: EntitlementDetails, + status: EntitlementStatus, + source?: SnapshotSource, +): void { + if (details.status === status && details.source === source) { + return; + } + + throw new Error( + `Expected ${status}/${source}, got ${details.status}/${details.source}`, + ); +} + +test( + `snapshot stored (${LATENCY})`, + { timeout: TIMEOUT_MS }, + async ({ bench }) => { + await bench.compare( + // Redis GET. + bench( + "cache hit: Redis", + { beforeAll: () => stack.prime(CACHE_HIT) }, + async () => { + const details = await stack.blocking.getDetails( + FEATURE_ID, + DEFAULT_SEATS, + CACHE_HIT, + ); + expectPath(details, "granted", "cache"); + }, + ), + + // Redis GET miss, Postgres SELECT, Redis SET backfill. + bench( + "store hit: Redis miss, Postgres", + { + beforeAll: () => stack.prime(STORE_HIT), + beforeEach: () => stack.evictCache(STORE_HIT), + }, + async () => { + const details = await stack.blocking.getDetails( + FEATURE_ID, + DEFAULT_SEATS, + STORE_HIT, + ); + expectPath(details, "granted", "store"); + }, + ), + + STORED_RUN, + ); + }, +); + +test( + `snapshot missing (${LATENCY})`, + { timeout: TIMEOUT_MS }, + async ({ bench }) => { + let refreshed: Promise = Promise.resolve(); + + await bench.compare( + // Redis and Postgres miss, Chargebee, Postgres upsert, Redis SET. + bench( + "cold miss, blocking: Chargebee", + { beforeEach: () => stack.forget(BLOCKING_MISS) }, + async () => { + const details = await stack.blocking.getDetails( + FEATURE_ID, + DEFAULT_SEATS, + BLOCKING_MISS, + ); + expectPath(details, "granted", "api"); + }, + ), + + // Redis and Postgres miss, answers `pending`; Chargebee runs untimed. + bench( + "cold miss, background: pending", + { + beforeEach: async () => { + await stack.forget(BACKGROUND_MISS); + refreshed = stack.nextRefresh(); + }, + afterEach: () => refreshed, + }, + async () => { + const details = await stack.background.getDetails( + FEATURE_ID, + DEFAULT_SEATS, + BACKGROUND_MISS, + ); + expectPath(details, "pending"); + }, + ), + + // Chargebee, Postgres upsert, Redis DEL; skips both reads. + bench("getFromAPI: Chargebee", async () => { + const seats = await stack.blocking.getFromAPI( + FEATURE_ID, + DEFAULT_SEATS, + FROM_API, + ); + if (seats === DEFAULT_SEATS) { + throw new Error("getFromAPI fell back to the default"); + } + }), + + MISSING_RUN, + ); + }, +); diff --git a/packages/entitlements/bench/support/chargebee.ts b/packages/entitlements/bench/support/chargebee.ts new file mode 100644 index 0000000..d0625b7 --- /dev/null +++ b/packages/entitlements/bench/support/chargebee.ts @@ -0,0 +1,50 @@ +import Chargebee, { type HttpClientInterface } from "chargebee"; + +/** + * Chargebee SDK whose HTTP round trip is replaced by a canned one-page + * response after `delayMs`. Every customer is on the same plan. + */ +export function createMockChargebee(delayMs: number): Chargebee { + const httpClient: HttpClientInterface = { + async makeApiRequest() { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return Response.json({ + list: PLAN.map((row) => ({ customer_entitlement: row })), + }); + }, + }; + + return new Chargebee({ site: "bench", apiKey: "bench", httpClient }); +} + +const PLAN = [ + { + feature_id: "advanced-reports", + feature_name: "Advanced reports", + feature_type: "switch", + value: "true", + is_enabled: true, + }, + { + feature_id: "licensed-seats", + feature_name: "Licensed seats", + feature_type: "quantity", + feature_unit: "seat", + value: "25", + is_enabled: true, + }, + { + feature_id: "api-calls", + feature_name: "API calls per month", + feature_type: "range", + value: "unlimited", + is_enabled: true, + }, + { + feature_id: "support-tier", + feature_name: "Support tier", + feature_type: "custom", + value: "priority", + is_enabled: true, + }, +]; diff --git a/packages/entitlements/bench/support/config.ts b/packages/entitlements/bench/support/config.ts new file mode 100644 index 0000000..0a9c4bd --- /dev/null +++ b/packages/entitlements/bench/support/config.ts @@ -0,0 +1,45 @@ +/** + * Benchmark flags, read from the environment: + * + * pnpm bench:up + * REDIS_DELAY_MS=1 POSTGRES_DELAY_MS=3 CHARGEBEE_DELAY_MS=250 pnpm bench + * pnpm bench:down + * + * Redis and Postgres delays are added to every round trip by Toxiproxy. + * The Chargebee delay is the mock API's response time. + */ + +const DEFAULT_CHARGEBEE_DELAY_MS = 500; + +function readDelayMs(name: string, fallback = 0): number { + const raw = process.env[name]; + if (raw === undefined || raw === "") { + return fallback; + } + + const value = Number(raw); + if (!Number.isFinite(value) || value < 0) { + throw new Error(`${name} must be a non-negative number of milliseconds`); + } + + return value; +} + +export const REDIS_DELAY_MS = readDelayMs("REDIS_DELAY_MS"); +export const POSTGRES_DELAY_MS = readDelayMs("POSTGRES_DELAY_MS"); +export const CHARGEBEE_DELAY_MS = readDelayMs( + "CHARGEBEE_DELAY_MS", + DEFAULT_CHARGEBEE_DELAY_MS, +); + +// Toxiproxy ports from docker-compose.yml. +export const TOXIPROXY_URL = "http://localhost:8474"; +export const REDIS_URL = "redis://localhost:16379"; +export const POSTGRES_URL = + "postgres://postgres:postgres@localhost:15432/entitlements"; + +// Proxy names from toxiproxy.json. +export enum ProxyName { + Redis = "redis", + Postgres = "postgres", +} diff --git a/packages/entitlements/bench/support/network.ts b/packages/entitlements/bench/support/network.ts new file mode 100644 index 0000000..6f712da --- /dev/null +++ b/packages/entitlements/bench/support/network.ts @@ -0,0 +1,44 @@ +import { type ProxyName, TOXIPROXY_URL } from "./config"; + +const TOXIC_NAME = "latency"; +const HTTP_NOT_FOUND = 404; + +async function toxiproxy(path: string, init?: RequestInit): Promise { + const response = await fetch(`${TOXIPROXY_URL}${path}`, init); + if (response.ok || response.status === HTTP_NOT_FOUND) { + return response; + } + + throw new Error( + `Toxiproxy ${init?.method ?? "GET"} ${path}: ${response.status} ${await response.text()}`, + ); +} + +/** + * Delays every response from `proxy` by `delayMs`, so each round trip pays + * it once, like a cloud network hop. 0 removes the delay. + */ +export async function setLatency( + proxy: ProxyName, + delayMs: number, +): Promise { + await toxiproxy(`/proxies/${proxy}/toxics/${TOXIC_NAME}`, { + method: "DELETE", + }); + + if (delayMs === 0) { + return; + } + + await toxiproxy(`/proxies/${proxy}/toxics`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: TOXIC_NAME, + type: "latency", + stream: "downstream", + toxicity: 1, + attributes: { latency: delayMs, jitter: 0 }, + }), + }); +} diff --git a/packages/entitlements/bench/support/postgres.ts b/packages/entitlements/bench/support/postgres.ts new file mode 100644 index 0000000..bbce554 --- /dev/null +++ b/packages/entitlements/bench/support/postgres.ts @@ -0,0 +1,51 @@ +import type { Pool } from "pg"; +import { + type EntitlementsStorage, + parseSerializedEntitlementsSnapshot, + serializeEntitlementsSnapshot, +} from "../../src/cache"; + +/** + * Durable snapshot store over Postgres. Rows never expire: the client serves + * an expired snapshot while it refreshes, so `ttlMs` is ignored. + */ +export function createPostgresStore(pool: Pool): EntitlementsStorage { + return { + async get(key) { + const { rows } = await pool.query<{ document: string }>( + "select document from entitlements_snapshot where key = $1", + [key], + ); + const row = rows[0]; + if (!row) { + return undefined; + } + + return parseSerializedEntitlementsSnapshot(row.document); + }, + async set(key, value) { + await pool.query( + `insert into entitlements_snapshot (key, document) values ($1, $2) + on conflict (key) do update set document = excluded.document`, + [key, serializeEntitlementsSnapshot(value)], + ); + }, + async delete(key) { + await pool.query("delete from entitlements_snapshot where key = $1", [ + key, + ]); + }, + async clear() { + await pool.query("truncate entitlements_snapshot"); + }, + }; +} + +export async function migrate(pool: Pool): Promise { + await pool.query( + `create table if not exists entitlements_snapshot ( + key text primary key, + document text not null + )`, + ); +} diff --git a/packages/entitlements/bench/support/stack.ts b/packages/entitlements/bench/support/stack.ts new file mode 100644 index 0000000..3df6e8a --- /dev/null +++ b/packages/entitlements/bench/support/stack.ts @@ -0,0 +1,121 @@ +import Redis from "ioredis"; +import { Pool } from "pg"; +import { + createEntitlementsCacheKey, + createRedisEntitlementsCache, + type EntitlementsStorage, +} from "../../src/cache"; +import { + ChargebeeEntitlements, + type ChargebeeEntitlementsOptions, +} from "../../src/server"; +import type { ChargebeeTarget } from "../../src/shared"; +import { createMockChargebee } from "./chargebee"; +import { + CHARGEBEE_DELAY_MS, + POSTGRES_DELAY_MS, + POSTGRES_URL, + ProxyName, + REDIS_DELAY_MS, + REDIS_URL, +} from "./config"; +import { setLatency } from "./network"; +import { createPostgresStore, migrate } from "./postgres"; + +// Outlasts a run, so stored snapshots never trigger background refreshes. +const SNAPSHOT_TTL_MS = 60 * 60 * 1000; + +/** + * Production-shaped setup: Redis cache, Postgres store, mock Chargebee. + * + * ChargebeeEntitlements ──► Redis ──► Postgres ──► Chargebee (mock) + * + * `blocking` waits for Chargebee on a miss; `background` answers `pending`. + */ +export class BenchStack { + readonly blocking: ChargebeeEntitlements; + readonly background: ChargebeeEntitlements; + private readonly cache: EntitlementsStorage; + private readonly refreshWaiters: Array<() => void> = []; + + private constructor( + private readonly redis: Redis, + private readonly pool: Pool, + ) { + this.cache = createRedisEntitlementsCache(redis); + + const options: ChargebeeEntitlementsOptions = { + chargebeeClient: createMockChargebee(CHARGEBEE_DELAY_MS), + cache: this.cache, + durableStore: createPostgresStore(pool), + snapshotTtlMs: SNAPSHOT_TTL_MS, + onError: (error, info) => { + console.error(`${info.operation} failed`, error); + }, + }; + + this.blocking = new ChargebeeEntitlements({ + ...options, + refreshOnMiss: "blocking", + }); + + // Background refreshes finish after the call returns; waiters learn when. + this.background = new ChargebeeEntitlements({ + ...options, + refreshOnMiss: "background", + onSnapshotRefreshed: () => this.settleRefresh(), + onError: (error, info) => { + options.onError?.(error, info); + this.settleRefresh(); + }, + }); + } + + static async start(): Promise { + await setLatency(ProxyName.Redis, REDIS_DELAY_MS); + await setLatency(ProxyName.Postgres, POSTGRES_DELAY_MS); + + const redis = new Redis(REDIS_URL); + const pool = new Pool({ connectionString: POSTGRES_URL }); + + // Start empty, so leftovers from earlier runs can't change the paths taken. + await redis.flushdb(); + await migrate(pool); + await createPostgresStore(pool).clear?.(); + + return new BenchStack(redis, pool); + } + + /** Loads the snapshot into Redis and Postgres. */ + async prime(target: ChargebeeTarget): Promise { + await this.blocking.getSnapshot(target); + } + + /** Drops the Redis copy only, so the next read falls through to Postgres. */ + async evictCache(target: ChargebeeTarget): Promise { + await this.cache.delete(createEntitlementsCacheKey(target)); + } + + /** Drops the snapshot from Redis and Postgres. */ + async forget(target: ChargebeeTarget): Promise { + await this.blocking.deleteSnapshot(target); + } + + /** Resolves when the next background refresh lands or fails. */ + nextRefresh(): Promise { + return new Promise((resolve) => this.refreshWaiters.push(resolve)); + } + + async close(): Promise { + await this.blocking.close(); + await this.background.close(); + await this.redis.quit(); + await this.pool.end(); + } + + private settleRefresh(): void { + for (const resolve of this.refreshWaiters.splice(0)) { + resolve(); + } + } +} diff --git a/packages/entitlements/bench/toxiproxy.json b/packages/entitlements/bench/toxiproxy.json new file mode 100644 index 0000000..058661e --- /dev/null +++ b/packages/entitlements/bench/toxiproxy.json @@ -0,0 +1,14 @@ +[ + { + "name": "redis", + "listen": "0.0.0.0:16379", + "upstream": "redis:6379", + "enabled": true + }, + { + "name": "postgres", + "listen": "0.0.0.0:15432", + "upstream": "postgres:5432", + "enabled": true + } +] diff --git a/packages/entitlements/bench/tsconfig.json b/packages/entitlements/bench/tsconfig.json new file mode 100644 index 0000000..f2722d6 --- /dev/null +++ b/packages/entitlements/bench/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "composite": false, + "incremental": false, + "declaration": false, + "declarationDir": null, + "emitDeclarationOnly": false, + "noEmit": true + }, + "include": ["./**/*"] +} diff --git a/packages/entitlements/docs/architecture.html b/packages/entitlements/docs/architecture.html new file mode 100644 index 0000000..3930fea --- /dev/null +++ b/packages/entitlements/docs/architecture.html @@ -0,0 +1,2084 @@ + + + + + +Entitlement Resolution · Architecture + + + + + + + + + + +
+ + @chargebee/entitlements +

Entitlement resolution

+

How a feature check finds its snapshot: cache → durable store → Chargebee API. Pick a scenario, step through it, or click a node. The toggle sets refreshOnMiss, which only changes the cold-miss path.

+
+ +
+ +
+ + +
+ + +
+ User + App / SDK + Chargebee + Storage + Webhook +
+ + + + + + + + + +
+ + + + +

Shortcuts: Space play/pause · ←/→ step · 1–8 flow · O refreshOnMiss · T theme · F fullscreen · R reset layout · Drag nodes to reposition

+ +
+
+
+ Scenario · Cold miss · blocking +
+ + + + 0 / 0 +
+
+
+
+ +
+ + +
+
+
User / Browser
+
session · web client
+
logged in
+ · +
+
+
+
App server
+
route · relay
+
Express / Next.js
+ · +
+
+
+
Entitlements SDK
+
ChargebeeEntitlements
+
dedupe · backoff
+ · +
+
+
+
Cache
+
Redis / memory LRU
+
cacheTtlMs 60s
+ · +
+
+
+
Durable store
+
Postgres (yours)
+
snapshotTtlMs 5m
+ · +
+
+
+
Chargebee API
+
entitlements API
+
100/page
+ · +
+
+
+
Webhook worker
+
refreshSnapshot
+
POST /webhooks
+ · +
+ + + + +
+
+ + +
+ + + + diff --git a/packages/entitlements/docs/architecture.md b/packages/entitlements/docs/architecture.md new file mode 100644 index 0000000..a4bc86b --- /dev/null +++ b/packages/entitlements/docs/architecture.md @@ -0,0 +1,93 @@ +# Entitlement resolution + +Companion to [`architecture.html`](./architecture.html). Open it in a browser to step through each scenario. + +## Components + +| Node | Role | +|---|---| +| User / Browser | Logged-in user. Server pages, or the `@chargebee/entitlements/web` client. | +| App server | Route, middleware, or relay handler. Derives the target from the session. | +| Entitlements SDK | `ChargebeeEntitlements` (server). Resolves snapshots, dedupes in-flight refreshes, backs off after failures. | +| Cache | `cache` option: Redis or in-memory LRU. Entry TTL `cacheTtlMs` (60s default). | +| Durable store | `durableStore` option: source of truth, e.g. a Postgres table. User-supplied `EntitlementsStorage`; the library ships no Postgres adapter. | +| Chargebee API | Customer or subscription entitlements, 100 per page, max 50 pages. | +| Webhook worker | Calls `refreshSnapshot` / `writeSnapshot` / `deleteSnapshot`. | + +Lookup order for every evaluation: + +``` +get(feature, default, target) + └─ cache.get ──hit──▶ serve (refresh in background if expired) + │ miss/error + ▼ + store.get ──hit──▶ backfill cache, serve (refresh in background if expired) + │ miss/error + ▼ + refreshOnMiss = blocking ──▶ await Chargebee → store.set → cache.set → serve + refreshOnMiss = background ──▶ start fetch, return default (status "pending") +``` + +Two TTLs matter: + +- `cacheTtlMs`: when the cache entry disappears. A vanished entry is a plain miss. +- `snapshotTtlMs` (5 min): sets `snapshot.expiresAt`. Past it, the snapshot is served stale and refreshed in the background. + +## Mode toggle: `refreshOnMiss` + +Changes only the cold-miss path. `blocking` (default) waits on Chargebee; `background` returns defaults with status `pending`. + +## Scenarios + +### 1a. Cold miss, blocking +No snapshot in cache or store (e.g. first login). +1. User → App: request; target from session. +2. App → SDK: `get(featureId, default, target)`. +3. SDK → Cache: miss. +4. SDK → Store: miss. +5. SDK → Chargebee: fetch, awaited. Concurrent requests share it. +6. Chargebee → SDK: paginated entitlements → snapshot. +7. SDK → Store: persist. Failure propagates → `error` / `unavailable`. +8. SDK → Cache: populate. Failure swallowed. +9. SDK → App: `granted`, `source: "api"`. +10. App → User: rendered. + +### 1b. Cold miss, background +Steps 1–4 as above, then: +5. SDK → App: default, `status: "pending"` (`SnapshotPendingError` caught). +6. App → User: rendered with defaults. +7–10. Background fetch → store → cache. Skipped within `refreshBackoffMs` after a failure. + +### 2. Store hit, cache miss +1–3 as above. Store returns the snapshot; SDK backfills the cache; returns `source: "store"`. If the row is expired, it is still served and a background refresh starts. + +### 3. Cache hit +Cache returns a fresh snapshot; `source: "cache"`. Store and Chargebee untouched. + +### 4. Expired snapshot (stale-while-revalidate) +Cache returns a snapshot past `expiresAt`. It is served (`source: "cache"`) and a deduplicated background refresh updates store and cache. Happens when `cacheTtlMs ≥ snapshotTtlMs` or an expired store row was backfilled. With defaults the cache entry vanishes first, so this path is usually reached through the store (scenario 2 + background refresh). + +### 5. Outage +Cache read throws → `onError(cache-read)`, treated as a miss. Store returns an expired row; cache backfill fails silently. Refresh fails → `onError(refresh)`, retries suppressed for 10s. Expired store snapshot served. With no store row: blocking → `error` / `unavailable`; background → `pending`. `get()` never throws. + +### 6. Webhook refresh +Chargebee → Webhook → `refreshSnapshot(target)`: cancel in-flight request refreshes, evict cache, fetch, persist to store and cache, `onSnapshotRefreshed({ trigger: "explicit" })`. A memory cache evicts only in the handling process. Alternatives: `writeSnapshot` (snapshot built from the payload), `deleteSnapshot` (clears cache and store). + +### 6b. API read +`getFromAPI(featureId, default, target)`: cancel in-flight refreshes, skip cache and store, fetch, write store, evict cache, `onSnapshotRefreshed({ trigger: "request" })`. No backoff, no fallback: failure → default, `onError(refresh)`. + +### 7. Browser relay +Browser → `GET /api/entitlements` → `createEntitlementsRelayHandler` → `getRelaySnapshot(target)` (same resolution chain) → `expiresAt` rewritten to `now + relayTtlMs` (60s) → `200` with `Cache-Control: no-store`. Guards: non-GET 405, identity query params 400, no session 401, failure 502. The browser evaluates synchronously: no snapshot → `pending`; expired → `stale` plus one background refetch. + +## Per-feature outcomes (any scenario) + +| Status | When | Value | +|---|---|---| +| `granted` | Enabled, parses as the default's type | Entitlement value | +| `disabled` | Disabled, or entitlement `expiresAt` passed | Default | +| `error` / `not-found` | Feature absent from snapshot | Default | +| `error` / `type-mismatch` | Value doesn't parse as the default's type | Default | +| `error` / `invalid-target` | Neither or both of `customerId` / `subscriptionId` | Default | +| `error` / `unavailable` | Snapshot couldn't be loaded | Default | +| `pending` | Background cold miss, or browser not loaded | Default | +| `stale` | Browser snapshot expired, refetching | Default | diff --git a/packages/entitlements/examples/express/.env.example b/packages/entitlements/examples/express/.env.example new file mode 100644 index 0000000..557758a --- /dev/null +++ b/packages/entitlements/examples/express/.env.example @@ -0,0 +1,4 @@ +# Copy to .env to call your Chargebee site. Leave unset to use the mock API. +CHARGEBEE_SITE= +CHARGEBEE_API_KEY= +PORT=3000 diff --git a/packages/entitlements/examples/express/.gitignore b/packages/entitlements/examples/express/.gitignore new file mode 100644 index 0000000..37d7e73 --- /dev/null +++ b/packages/entitlements/examples/express/.gitignore @@ -0,0 +1,2 @@ +node_modules +.env diff --git a/packages/entitlements/examples/express/README.md b/packages/entitlements/examples/express/README.md new file mode 100644 index 0000000..6d6ed88 --- /dev/null +++ b/packages/entitlements/examples/express/README.md @@ -0,0 +1,38 @@ +# Express example + +Gates Express routes with `@chargebee/entitlements` on the server. + +```sh +# once, from packages/entitlements: build the package this example links to +pnpm install && pnpm build + +# then, from this folder +pnpm i +pnpm start +``` + +Open http://localhost:3000. Requires Node.js 22.18 or later, which runs the TypeScript sources directly. + +## Mock or real Chargebee + +Without `CHARGEBEE_SITE` and `CHARGEBEE_API_KEY` in `.env`, the Chargebee SDK's HTTP client is replaced with [canned responses](./src/mock-chargebee.ts) and each call is logged as `[mock chargebee]`. The page then lists the demo users with what each should get, and lets you sign in as one and call the routes from the browser. + +With your site configured, the page lists curl commands instead, and requests name the user in the `x-user` header. To use your site, copy `.env.example` to `.env`, fill it in, and point the demo users in [`src/users.ts`](./src/users.ts) and the features in [`src/entitlements.ts`](./src/entitlements.ts) at your customer and feature IDs. + +## What to look for + +| User | Plan | Shows | +| --- | --- | --- | +| `alice` | Pro | Every feature `granted`; `/reports` allowed | +| `bob` | Free | `advanced-reports` is `disabled` (403 on `/reports`); `support-tier` is `not-found` and falls back to its default; all 3 seats taken | +| `carol` | none | Customer missing from Chargebee: every feature resolves to its default | + +- `GET /me/entitlements` twice: `source` changes from `api` to `cache`, and the mock logs one call. +- `POST /webhooks/chargebee` refetches the customer's snapshot, as a subscription change would. + +## Files + +- [`src/entitlements.ts`](./src/entitlements.ts): the client, its memory cache, and the feature declarations. +- [`src/server.ts`](./src/server.ts): routes, the `requireFeature` middleware, and the webhook. +- [`src/demo-page.ts`](./src/demo-page.ts): the mock-mode page. +- [`src/chargebee.ts`](./src/chargebee.ts): picks the real or the mock Chargebee client. diff --git a/packages/entitlements/examples/express/package.json b/packages/entitlements/examples/express/package.json new file mode 100644 index 0000000..f360212 --- /dev/null +++ b/packages/entitlements/examples/express/package.json @@ -0,0 +1,23 @@ +{ + "name": "entitlements-example-express", + "private": true, + "type": "module", + "engines": { + "node": ">=22.18" + }, + "scripts": { + "start": "node --env-file-if-exists=.env src/server.ts", + "dev": "node --env-file-if-exists=.env --watch src/server.ts", + "typecheck": "tsc" + }, + "dependencies": { + "@chargebee/entitlements": "file:../..", + "chargebee": "^3.34.0", + "express": "^5.2.1" + }, + "devDependencies": { + "@types/express": "^5.0.3", + "@types/node": "^22.18.0", + "typescript": "^5.9.2" + } +} diff --git a/packages/entitlements/examples/express/pnpm-lock.yaml b/packages/entitlements/examples/express/pnpm-lock.yaml new file mode 100644 index 0000000..daa3000 --- /dev/null +++ b/packages/entitlements/examples/express/pnpm-lock.yaml @@ -0,0 +1,712 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@chargebee/entitlements': + specifier: file:../.. + version: file:../..(chargebee@3.34.0) + chargebee: + specifier: ^3.34.0 + version: 3.34.0 + express: + specifier: ^5.2.1 + version: 5.2.1 + devDependencies: + '@types/express': + specifier: ^5.0.3 + version: 5.0.6 + '@types/node': + specifier: ^22.18.0 + version: 22.20.5 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + +packages: + + '@chargebee/entitlements@file:../..': + resolution: {directory: ../.., type: directory} + engines: {node: '>=22'} + peerDependencies: + chargebee: ^3.30.0 + ioredis: ^6.0.0 + next: ^16.0.0 + peerDependenciesMeta: + ioredis: + optional: true + next: + optional: true + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/express-serve-static-core@5.1.3': + resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/node@22.20.5': + resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chargebee@3.34.0: + resolution: {integrity: sha512-ODO/NvUCRlpvZpMu0jhzsnQ0GG5n4clL5fSqtAzYFbLIm6qoXGiq0O/fPNTLAmkaKt8Ejh42V21H4+15WlK8aw==} + engines: {node: '>=18.*'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + +snapshots: + + '@chargebee/entitlements@file:../..(chargebee@3.34.0)': + dependencies: + chargebee: 3.34.0 + server-only: 0.0.1 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.20.5 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.20.5 + + '@types/express-serve-static-core@5.1.3': + dependencies: + '@types/node': 22.20.5 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.3 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/node@22.20.5': + dependencies: + undici-types: 6.21.0 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 22.20.5 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.20.5 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chargebee@3.34.0: + dependencies: + zod: 4.6.5 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-to-regexp@8.4.2: {} + + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + server-only@0.0.1: {} + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + statuses@2.0.2: {} + + toidentifier@1.0.1: {} + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + wrappy@1.0.2: {} + + zod@4.6.5: {} diff --git a/packages/entitlements/examples/express/pnpm-workspace.yaml b/packages/entitlements/examples/express/pnpm-workspace.yaml new file mode 100644 index 0000000..a66b7a9 --- /dev/null +++ b/packages/entitlements/examples/express/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +# Standalone install root, separate from the repository's workspace. +packages: + - . diff --git a/packages/entitlements/examples/express/src/chargebee.ts b/packages/entitlements/examples/express/src/chargebee.ts new file mode 100644 index 0000000..fff6229 --- /dev/null +++ b/packages/entitlements/examples/express/src/chargebee.ts @@ -0,0 +1,25 @@ +import Chargebee from "chargebee"; +import { mockHttpClient } from "./mock-chargebee.ts"; + +const MOCK_SITE = "mock-site"; +const MOCK_API_KEY = "mock-api-key"; + +const site = process.env.CHARGEBEE_SITE; +const apiKey = process.env.CHARGEBEE_API_KEY; + +export const isMockChargebee = !site || !apiKey; + +/** Uses your Chargebee site when `.env` has credentials, the mock otherwise. */ +export function createChargebee(): Chargebee { + if (site && apiKey) { + console.log(`Chargebee: site "${site}"`); + return new Chargebee({ site, apiKey }); + } + + console.log("Chargebee: no credentials in .env, using the mock API"); + return new Chargebee({ + site: MOCK_SITE, + apiKey: MOCK_API_KEY, + httpClient: mockHttpClient, + }); +} diff --git a/packages/entitlements/examples/express/src/demo-page.ts b/packages/entitlements/examples/express/src/demo-page.ts new file mode 100644 index 0000000..a867593 --- /dev/null +++ b/packages/entitlements/examples/express/src/demo-page.ts @@ -0,0 +1,163 @@ +import type { User } from "./users.ts"; + +export interface FeatureSummary { + featureId: string; + value: unknown; + status: string; + source?: string; + error?: string; +} + +interface DemoPageOptions { + users: readonly User[]; + user: User | undefined; + features: FeatureSummary[]; + userField: string; +} + +/** An API route the page can call, with its response shown below the buttons. */ +interface ApiAction { + method: "GET" | "POST"; + url: string; + body?: string; +} + +const STYLES = ` +body { margin: 0; font-family: system-ui, sans-serif; color: #1f2328; background: #f6f8fa; } +main { max-width: 760px; margin: 0 auto; padding: 2rem 1rem; } +section { margin-top: 1.5rem; padding: 1rem 1.25rem; background: #fff; border: 1px solid #d0d7de; border-radius: 8px; } +h2 { margin-top: 0; font-size: 1.1rem; } +table { width: 100%; border-collapse: collapse; } +th, td { padding: 0.4rem 0.5rem; text-align: left; border-bottom: 1px solid #eaeef2; } +button { margin: 0 0.5rem 0.5rem 0; padding: 0.35rem 0.8rem; cursor: pointer; } +pre { padding: 0.75rem; background: #f6f8fa; border-radius: 6px; overflow-x: auto; } +.profiles { margin: 0 0 1rem; padding: 0; list-style: none; } +.profiles li { margin-bottom: 0.5rem; padding: 0.6rem 0.8rem; border: 1px solid #d0d7de; border-radius: 6px; } +.profiles li.current { border-color: #1a7f37; background: #f0fff4; } +.profiles p { margin: 0.25rem 0 0.5rem; } +.notice { padding: 0.6rem 1rem; background: #fff8c5; border: 1px solid #d4a72c; border-radius: 8px; } +.muted { color: #656d76; } +.granted { color: #1a7f37; } +.disabled, .error { color: #cf222e; } +`; + +/** Sends each action's request and prints the status and JSON body. */ +const SCRIPT = ` +const output = document.getElementById("output"); +for (const button of document.querySelectorAll("[data-url]")) { + button.addEventListener("click", async () => { + const { method, url, body } = button.dataset; + const headers = body ? { "Content-Type": "application/json" } : {}; + const response = await fetch(url, { method, headers, body }); + const text = await response.text(); + let pretty = text; + try { pretty = JSON.stringify(JSON.parse(text), null, 2); } catch {} + output.textContent = method + " " + url + " → " + response.status + "\\n\\n" + pretty; + }); +} +`; + +const escapeHtml = (value: unknown) => + String(value).replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); + +function renderProfiles(options: DemoPageOptions): string { + const items = options.users + .map((user) => { + const current = user.id === options.user?.id; + return `
  • + ${escapeHtml(user.name)} +

    ${escapeHtml(user.description)}

    + +
  • `; + }) + .join("\n"); + + return `
    +
      ${items}
    + +
    `; +} + +function renderFeatures(features: FeatureSummary[]): string { + const rows = features + .map( + (feature) => ` + ${escapeHtml(feature.featureId)} + ${escapeHtml(feature.value)} + ${escapeHtml(feature.status)} + ${escapeHtml(feature.source ?? "none")} +`, + ) + .join("\n"); + + return ` + + ${rows} +
    FeatureValueStatusSource
    `; +} + +function renderActions(user: User): string { + // The webhook a subscription change for this user's customer would send. + const webhookBody = JSON.stringify({ + event_type: "subscription_changed", + content: { subscription: { customer_id: user.chargebeeCustomerId } }, + }); + const actions: ApiAction[] = [ + { method: "GET", url: "/me/entitlements" }, + { method: "GET", url: "/reports" }, + { method: "POST", url: "/team/members" }, + { method: "POST", url: "/webhooks/chargebee", body: webhookBody }, + ]; + + const buttons = actions + .map( + (action) => + ``, + ) + .join("\n"); + + return `${buttons} +
    Pick a request.
    `; +} + +/** The mock-mode page: switch demo users and call the API from the browser. */ +export function renderDemoPage(options: DemoPageOptions): string { + const { user } = options; + const signedIn = user + ? `
    +

    Entitlements of ${escapeHtml(user.name)}

    + ${renderFeatures(options.features)} +

    Reload: source moves from api to cache.

    +
    +
    +

    Try the API

    + ${renderActions(user)} +
    ` + : `

    Sign in as a demo user to see their entitlements.

    `; + + return ` + + + + Chargebee entitlements, Express example + + + +
    +

    Chargebee entitlements

    +

    Express.js example - server side checks only

    +

    No Chargebee credentials in .env: answering from the mock API.

    +
    +

    Demo users

    + ${renderProfiles(options)} +
    + ${signedIn} +
    + + +`; +} diff --git a/packages/entitlements/examples/express/src/entitlements.ts b/packages/entitlements/examples/express/src/entitlements.ts new file mode 100644 index 0000000..c9fb83b --- /dev/null +++ b/packages/entitlements/examples/express/src/entitlements.ts @@ -0,0 +1,25 @@ +import { createMemoryEntitlementsCache } from "@chargebee/entitlements/cache"; +import { ChargebeeEntitlements } from "@chargebee/entitlements/server"; +import { createChargebee } from "./chargebee.ts"; + +const CACHE_TTL_MS = 30_000; + +/** + * One client per process. The memory cache absorbs repeat checks, so a + * customer's entitlements are fetched from Chargebee once per `CACHE_TTL_MS`. + */ +export const entitlements = new ChargebeeEntitlements({ + chargebeeClient: createChargebee(), + cache: createMemoryEntitlementsCache({ ttlMs: CACHE_TTL_MS }), + onError: (error, { operation, target }) => { + console.error(`[entitlements] ${operation} failed`, target, error); + }, +}); + +// Defaults apply when Chargebee has no usable value, so they match the lowest tier. +export const advancedReports = entitlements.feature("advanced-reports", false); +export const licensedSeats = entitlements.feature("licensed-seats", 1); +export const apiCalls = entitlements.feature("api-calls", 100); +export const supportTier = entitlements.feature("support-tier", "community"); + +export const FEATURES = [advancedReports, licensedSeats, apiCalls, supportTier]; diff --git a/packages/entitlements/examples/express/src/mock-chargebee.ts b/packages/entitlements/examples/express/src/mock-chargebee.ts new file mode 100644 index 0000000..dceac90 --- /dev/null +++ b/packages/entitlements/examples/express/src/mock-chargebee.ts @@ -0,0 +1,127 @@ +import type { HttpClientInterface } from "chargebee"; + +/** + * Answers Chargebee API calls with canned data when `.env` has no + * credentials. The real SDK still builds every request; only the HTTP round + * trip is replaced: + * + * ChargebeeEntitlements ─► chargebee SDK ─► mockHttpClient ─► canned JSON + */ + +const LATENCY_MS = 300; +const HTTP_NOT_FOUND = 404; +const CUSTOMER_ENTITLEMENTS_PATH = + /\/customers\/([^/]+)\/customer_entitlements$/; + +/** An entitlement row as the Chargebee API returns it. */ +interface RawEntitlement { + feature_id: string; + feature_name: string; + feature_type: "switch" | "quantity" | "range" | "custom"; + feature_unit?: string; + value: string; + is_enabled: boolean; +} + +const PRO_PLAN: RawEntitlement[] = [ + { + feature_id: "advanced-reports", + feature_name: "Advanced reports", + feature_type: "switch", + value: "true", + is_enabled: true, + }, + { + feature_id: "licensed-seats", + feature_name: "Licensed seats", + feature_type: "quantity", + feature_unit: "seat", + value: "25", + is_enabled: true, + }, + { + feature_id: "api-calls", + feature_name: "API calls per month", + feature_type: "range", + value: "unlimited", + is_enabled: true, + }, + { + feature_id: "support-tier", + feature_name: "Support tier", + feature_type: "custom", + value: "priority", + is_enabled: true, + }, +]; + +/** No `support-tier` row, so that feature resolves to its default. */ +const FREE_PLAN: RawEntitlement[] = [ + { + feature_id: "advanced-reports", + feature_name: "Advanced reports", + feature_type: "switch", + value: "false", + is_enabled: false, + }, + { + feature_id: "licensed-seats", + feature_name: "Licensed seats", + feature_type: "quantity", + feature_unit: "seat", + value: "3", + is_enabled: true, + }, + { + feature_id: "api-calls", + feature_name: "API calls per month", + feature_type: "range", + value: "1000", + is_enabled: true, + }, +]; + +/** Any other customer ID gets a 404, like a customer missing from the site. */ +const CUSTOMERS: Record = { + cust_pro: PRO_PLAN, + cust_free: FREE_PLAN, +}; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Chargebee's error body for an unknown resource. */ +function notFound(message: string): Response { + return Response.json( + { + message, + type: "invalid_request", + api_error_code: "resource_not_found", + http_status_code: HTTP_NOT_FOUND, + }, + { status: HTTP_NOT_FOUND }, + ); +} + +export const mockHttpClient: HttpClientInterface = { + async makeApiRequest(request) { + const { pathname } = new URL(request.url); + console.log(`[mock chargebee] ${request.method} ${pathname}`); + await sleep(LATENCY_MS); + + const match = CUSTOMER_ENTITLEMENTS_PATH.exec(pathname); + if (!match?.[1]) { + return notFound(`No mock for ${pathname}`); + } + + const customerId = decodeURIComponent(match[1]); + const entitlements = CUSTOMERS[customerId]; + if (!entitlements) { + return notFound(`Customer ${customerId} not found`); + } + + // One page: no `next_offset`. + return Response.json({ + list: entitlements.map((row) => ({ customer_entitlement: row })), + }); + }, +}; diff --git a/packages/entitlements/examples/express/src/server.ts b/packages/entitlements/examples/express/src/server.ts new file mode 100644 index 0000000..946c4cb --- /dev/null +++ b/packages/entitlements/examples/express/src/server.ts @@ -0,0 +1,247 @@ +import type { + ChargebeeTarget, + EntitlementDetails, + Feature, + TargetedEntitlementsClient, +} from "@chargebee/entitlements"; +import express, { + type NextFunction, + type Request, + type Response, +} from "express"; +import { isMockChargebee } from "./chargebee.ts"; +import { type FeatureSummary, renderDemoPage } from "./demo-page.ts"; +import { + advancedReports, + entitlements, + FEATURES, + licensedSeats, +} from "./entitlements.ts"; +import { findUser, listUsers, USER_IDS, type User } from "./users.ts"; + +const PORT = Number(process.env.PORT ?? 3000); +const USER_HEADER = "x-user"; +const SESSION_COOKIE = "demo-user"; +const USER_FIELD = "userId"; + +const HttpStatus = { + Ok: 200, + Created: 201, + SeeOther: 303, + Unauthorized: 401, + Forbidden: 403, +} as const; + +/** Chargebee events that can change a customer's entitlements. */ +const ENTITLEMENT_EVENTS = new Set([ + "subscription_created", + "subscription_changed", + "subscription_cancelled", + "subscription_reactivated", + "subscription_entitlements_updated", +]); + +interface AuthLocals { + user: User; + target: ChargebeeTarget; +} + +type AuthResponse = Response; + +/** `Infinity` (an "unlimited" entitlement) would serialize to `null`. */ +const formatValue = (value: unknown) => + value === Number.POSITIVE_INFINITY ? "unlimited" : value; + +const summarize = ( + featureId: string, + details: EntitlementDetails, +): FeatureSummary => ({ + featureId, + value: formatValue(details.value), + status: details.status, + source: details.source, + error: details.error?.message, +}); + +/** Every feature with its value and why: granted, disabled, or an error. */ +function summarizeFeatures(target: ChargebeeTarget): Promise { + return Promise.all( + FEATURES.map(async (feature) => + summarize(feature.featureId, await feature.getDetails(target)), + ), + ); +} + +/** Reads one cookie; `cookie-parser` would do this in a larger app. */ +function readCookie(req: Request, name: string): string | undefined { + const cookie = req + .header("cookie") + ?.split(";") + .map((part) => part.trim().split("=")) + .find(([key]) => key === name); + + return cookie?.[1] ? decodeURIComponent(cookie[1]) : undefined; +} + +/** + * Stand-in for real auth: the `x-user` header names a demo user. With mock + * data, the browser page's session cookie works too. + */ +function sessionUser(req: Request): User | undefined { + const header = req.header(USER_HEADER); + if (header || !isMockChargebee) { + return findUser(header); + } + + return findUser(readCookie(req, SESSION_COOKIE)); +} + +function authenticate(req: Request, res: AuthResponse, next: NextFunction) { + const user = sessionUser(req); + if (!user) { + res.status(HttpStatus.Unauthorized).json({ + error: `Send an ${USER_HEADER} header, one of: ${USER_IDS.join(", ")}`, + }); + return; + } + + res.locals.user = user; + res.locals.target = { customerId: user.chargebeeCustomerId }; + next(); +} + +/** Rejects the request unless the customer is entitled to `feature`. */ +function requireFeature(feature: Feature) { + return async (_req: Request, res: AuthResponse, next: NextFunction) => { + if (await feature.get(res.locals.target)) { + next(); + return; + } + + res.status(HttpStatus.Forbidden).json({ + error: `Your plan does not include ${feature.featureId}`, + }); + }; +} + +const app = express(); + +/** Usage hints, with one curl line per demo user. */ +function usageText(): string { + const users = listUsers() + .map((user) => ` ${user.id.padEnd(6)} ${user.name}`) + .join("\n"); + + return `Chargebee entitlements, Express example + +Users (send as the ${USER_HEADER} header): +${users} + +Try: + curl -H "${USER_HEADER}: alice" localhost:${PORT}/me/entitlements + curl -H "${USER_HEADER}: bob" localhost:${PORT}/reports + curl -X POST -H "${USER_HEADER}: bob" localhost:${PORT}/team/members + curl -X POST -H "Content-Type: application/json" \\ + -d '{"event_type":"subscription_changed","content":{"subscription":{"customer_id":"cust_pro"}}}' \\ + localhost:${PORT}/webhooks/chargebee +`; +} + +// With mock data, a page to switch users and call the API from the browser. +// With your own site, the curl hints. +app.get("/", async (req, res) => { + if (!isMockChargebee) { + res.type("text").send(usageText()); + return; + } + + const user = sessionUser(req); + const features = user + ? await summarizeFeatures({ customerId: user.chargebeeCustomerId }) + : []; + + res.type("html").send( + renderDemoPage({ + users: listUsers(), + user, + features, + userField: USER_FIELD, + }), + ); +}); + +// Signs the browser page in as the submitted demo user, or out without one. +if (isMockChargebee) { + app.post("/session", express.urlencoded(), (req, res) => { + const user = findUser(req.body?.[USER_FIELD]); + if (user) { + res.cookie(SESSION_COOKIE, user.id, { httpOnly: true, sameSite: "lax" }); + } else { + res.clearCookie(SESSION_COOKIE); + } + + res.redirect(HttpStatus.SeeOther, "/"); + }); +} + +app.get("/me/entitlements", authenticate, async (_req, res: AuthResponse) => { + const { user, target } = res.locals; + const features = (await summarizeFeatures(target)).map( + ({ featureId, ...summary }) => [featureId, summary], + ); + + res.json({ user: user.name, features: Object.fromEntries(features) }); +}); + +// A switch feature gating a whole route. +app.get( + "/reports", + authenticate, + requireFeature(advancedReports), + (_req, res) => { + res.json({ report: "Revenue by region", rows: [["APAC", 1200]] }); + }, +); + +// A quantity feature capping how many members a team can have. +app.post("/team/members", authenticate, async (_req, res: AuthResponse) => { + const { user, target } = res.locals; + const seats = await licensedSeats.get(target); + if (user.teamSize >= seats) { + res.status(HttpStatus.Forbidden).json({ + error: `All ${seats} licensed seats are taken`, + }); + return; + } + + user.teamSize += 1; + res.status(HttpStatus.Created).json({ + teamSize: user.teamSize, + seats: formatValue(seats), + }); +}); + +// Drops the cached snapshot and refetches it when a subscription changes. +// Production webhooks must verify the request, e.g. with basic auth. +app.post("/webhooks/chargebee", express.json(), async (req, res) => { + const { event_type: eventType, content } = req.body ?? {}; + const customerId: string | undefined = content?.subscription?.customer_id; + if (!ENTITLEMENT_EVENTS.has(eventType) || !customerId) { + res.sendStatus(HttpStatus.Ok); + return; + } + + await entitlements.refreshSnapshot({ customerId }); + console.log(`[webhook] ${eventType}: refreshed ${customerId}`); + res.sendStatus(HttpStatus.Ok); +}); + +const server = app.listen(PORT, () => { + console.log(`Listening on http://localhost:${PORT}`); +}); + +// Stops in-flight background refreshes before exiting. +process.on("SIGINT", async () => { + await entitlements.close(); + server.close(); +}); diff --git a/packages/entitlements/examples/express/src/users.ts b/packages/entitlements/examples/express/src/users.ts new file mode 100644 index 0000000..56b57e7 --- /dev/null +++ b/packages/entitlements/examples/express/src/users.ts @@ -0,0 +1,49 @@ +export interface User { + id: string; + name: string; + /** What the mock API grants this user; your own site's data will differ. */ + description: string; + chargebeeCustomerId: string; + teamSize: number; +} + +/** + * Stands in for your user table. With real credentials, point + * `chargebeeCustomerId` at customers in your Chargebee site. + */ +const USERS: User[] = [ + { + id: "alice", + name: "Alice, Pro plan", + description: + "Advanced reports, 25 seats (4 used), unlimited API calls, and priority support.", + chargebeeCustomerId: "cust_pro", + teamSize: 4, + }, + { + id: "bob", + name: "Bob, Free plan", + description: + "Advanced reports disabled, 3 seats all used, and 1,000 API calls. Has no support tier, so it falls back to the default.", + chargebeeCustomerId: "cust_free", + teamSize: 3, + }, + { + id: "carol", + name: "Carol, not a customer", + description: + "Missing from Chargebee. Every feature falls back to its lowest-tier default.", + chargebeeCustomerId: "cust_missing", + teamSize: 0, + }, +]; + +export const USER_IDS = USERS.map((user) => user.id); + +export function findUser(id: string | undefined): User | undefined { + return USERS.find((user) => user.id === id); +} + +export function listUsers(): readonly User[] { + return USERS; +} diff --git a/packages/entitlements/examples/express/tsconfig.json b/packages/entitlements/examples/express/tsconfig.json new file mode 100644 index 0000000..150c67c --- /dev/null +++ b/packages/entitlements/examples/express/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/entitlements/examples/nextjs/.env.example b/packages/entitlements/examples/nextjs/.env.example new file mode 100644 index 0000000..446d8c4 --- /dev/null +++ b/packages/entitlements/examples/nextjs/.env.example @@ -0,0 +1,3 @@ +# Copy to .env to call your Chargebee site. Leave unset to use the mock API. +CHARGEBEE_SITE= +CHARGEBEE_API_KEY= diff --git a/packages/entitlements/examples/nextjs/.gitignore b/packages/entitlements/examples/nextjs/.gitignore new file mode 100644 index 0000000..a7dbcd3 --- /dev/null +++ b/packages/entitlements/examples/nextjs/.gitignore @@ -0,0 +1,5 @@ +node_modules +.next +next-env.d.ts +*.tsbuildinfo +.env diff --git a/packages/entitlements/examples/nextjs/README.md b/packages/entitlements/examples/nextjs/README.md new file mode 100644 index 0000000..31c3715 --- /dev/null +++ b/packages/entitlements/examples/nextjs/README.md @@ -0,0 +1,42 @@ +# Next.js example + +Checks entitlements in a Next.js 16 App Router app, both in server components and in the browser through the relay. + +```sh +# once, from packages/entitlements: build the package this example links to +pnpm install && pnpm build + +# then, from this folder +pnpm i +pnpm start +``` + +Open http://localhost:3000 and sign in as one of the demo users. + +## Mock or real Chargebee + +Without `CHARGEBEE_SITE` and `CHARGEBEE_API_KEY` in `.env`, the Chargebee SDK's HTTP client is replaced with [canned responses](./src/lib/mock-chargebee.ts). The page shows a notice and lists the demo users with what each should get. Each call is logged in the terminal as `[mock chargebee]`. To use your site, copy `.env.example` to `.env`, fill it in, and point the demo users in [`src/lib/users.ts`](./src/lib/users.ts) and the features in [`src/lib/features.ts`](./src/lib/features.ts) at your customer and feature IDs. + +## How it works + +``` + server component ──► entitlements.for(target) ─┐ + ├─► server client ─► memory cache ─► Chargebee + browser client ─GET /api/entitlements─► relay route ───────┘ +``` + +- The server client in [`src/lib/entitlements.ts`](./src/lib/entitlements.ts) holds the API key and caches snapshots in memory. +- The relay in [`src/app/api/entitlements/route.ts`](./src/app/api/entitlements/route.ts) takes the customer from the session cookie and returns a sanitized snapshot. It answers 401 when signed out and rejects `customerId` and `subscriptionId` query parameters. +- [`EntitlementsProvider`](./src/components/entitlements-provider.tsx) loads that snapshot into the browser client, and `useFeature(feature)` evaluates it synchronously. [`FeatureGate`](./src/components/feature-gate.tsx) uses it to show a component only when the feature is granted. +- [`/reports`](./src/app/reports/page.tsx) is gated on the server, so users without the feature never receive its content. + +## What to look for + +| User | Plan | Shows | +| --- | --- | --- | +| Alice | Pro | Every feature `granted`; `Export report` enabled | +| Bob | Free | `advanced-reports` is `disabled`; `support-tier` is `not-found` and falls back to its default | +| Carol | none | Customer missing from Chargebee: the server falls back to defaults, the relay answers 502 | + +- Reload the page: the server section's `source` changes from `api` to `cache`. +- The browser section reads from `relay` and stays `pending` until the snapshot arrives. diff --git a/packages/entitlements/examples/nextjs/next.config.ts b/packages/entitlements/examples/nextjs/next.config.ts new file mode 100644 index 0000000..6c68d75 --- /dev/null +++ b/packages/entitlements/examples/nextjs/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + agentRules: false, +}; + +export default nextConfig; diff --git a/packages/entitlements/examples/nextjs/package.json b/packages/entitlements/examples/nextjs/package.json new file mode 100644 index 0000000..e225c8b --- /dev/null +++ b/packages/entitlements/examples/nextjs/package.json @@ -0,0 +1,27 @@ +{ + "name": "entitlements-example-nextjs", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "start": "next dev", + "build": "next build", + "typecheck": "next typegen && tsc" + }, + "dependencies": { + "@chargebee/entitlements": "file:../..", + "chargebee": "^3.34.0", + "next": "^16.4.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "server-only": "^0.0.1" + }, + "devDependencies": { + "@types/node": "^22.18.0", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "typescript": "^5.9.2" + } +} diff --git a/packages/entitlements/examples/nextjs/pnpm-lock.yaml b/packages/entitlements/examples/nextjs/pnpm-lock.yaml new file mode 100644 index 0000000..d87feb1 --- /dev/null +++ b/packages/entitlements/examples/nextjs/pnpm-lock.yaml @@ -0,0 +1,686 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@chargebee/entitlements': + specifier: file:../.. + version: file:../..(chargebee@3.34.0)(next@16.4.0(@types/node@22.20.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)) + chargebee: + specifier: ^3.34.0 + version: 3.34.0 + next: + specifier: ^16.4.0 + version: 16.4.0(@types/node@22.20.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: + specifier: ^19.3.0 + version: 19.3.0 + react-dom: + specifier: ^19.3.0 + version: 19.3.0(react@19.3.0) + server-only: + specifier: ^0.0.1 + version: 0.0.1 + devDependencies: + '@types/node': + specifier: ^22.18.0 + version: 22.20.5 + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + typescript: + specifier: ^5.9.2 + version: 5.9.3 + +packages: + + '@chargebee/entitlements@file:../..': + resolution: {directory: ../.., type: directory} + engines: {node: '>=22'} + peerDependencies: + chargebee: ^3.30.0 + ioredis: ^6.0.0 + next: ^16.0.0 + peerDependenciesMeta: + ioredis: + optional: true + next: + optional: true + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@next/env@16.4.0': + resolution: {integrity: sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==} + + '@next/swc-darwin-arm64@16.4.0': + resolution: {integrity: sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.4.0': + resolution: {integrity: sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.4.0': + resolution: {integrity: sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@16.4.0': + resolution: {integrity: sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@16.4.0': + resolution: {integrity: sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@16.4.0': + resolution: {integrity: sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@16.4.0': + resolution: {integrity: sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.4.0': + resolution: {integrity: sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@types/node@22.20.5': + resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + baseline-browser-mapping@2.11.27: + resolution: {integrity: sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + caniuse-lite@1.0.30001814: + resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==} + + chargebee@3.34.0: + resolution: {integrity: sha512-ODO/NvUCRlpvZpMu0jhzsnQ0GG5n4clL5fSqtAzYFbLIm6qoXGiq0O/fPNTLAmkaKt8Ejh42V21H4+15WlK8aw==} + engines: {node: '>=18.*'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next@16.4.0: + resolution: {integrity: sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} + peerDependencies: + react: ^19.3.0 + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + +snapshots: + + '@chargebee/entitlements@file:../..(chargebee@3.34.0)(next@16.4.0(@types/node@22.20.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))': + dependencies: + chargebee: 3.34.0 + server-only: 0.0.1 + optionalDependencies: + next: 16.4.0(@types/node@22.20.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.4 + optional: true + + '@img/sharp-darwin-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.4 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + optional: true + + '@img/sharp-linux-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.4 + optional: true + + '@img/sharp-linux-arm@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.4 + optional: true + + '@img/sharp-linux-ppc64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.4 + optional: true + + '@img/sharp-linux-riscv64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.4 + optional: true + + '@img/sharp-linux-s390x@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.4 + optional: true + + '@img/sharp-linux-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + optional: true + + '@img/sharp-wasm32@0.35.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-win32-arm64@0.35.5': + optional: true + + '@img/sharp-win32-ia32@0.35.5': + optional: true + + '@img/sharp-win32-x64@0.35.5': + optional: true + + '@next/env@16.4.0': {} + + '@next/swc-darwin-arm64@16.4.0': + optional: true + + '@next/swc-darwin-x64@16.4.0': + optional: true + + '@next/swc-linux-arm64-gnu@16.4.0': + optional: true + + '@next/swc-linux-arm64-musl@16.4.0': + optional: true + + '@next/swc-linux-x64-gnu@16.4.0': + optional: true + + '@next/swc-linux-x64-musl@16.4.0': + optional: true + + '@next/swc-win32-arm64-msvc@16.4.0': + optional: true + + '@next/swc-win32-x64-msvc@16.4.0': + optional: true + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@types/node@22.20.5': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + baseline-browser-mapping@2.11.27: {} + + caniuse-lite@1.0.30001814: {} + + chargebee@3.34.0: + dependencies: + zod: 4.6.5 + + client-only@0.0.1: {} + + csstype@3.2.3: {} + + detect-libc@2.1.2: + optional: true + + nanoid@3.3.20: {} + + next@16.4.0(@types/node@22.20.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + '@next/env': 16.4.0 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.11.27 + caniuse-lite: 1.0.30001814 + postcss: 8.5.23 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + styled-jsx: 5.1.6(react@19.3.0) + optionalDependencies: + '@next/swc-darwin-arm64': 16.4.0 + '@next/swc-darwin-x64': 16.4.0 + '@next/swc-linux-arm64-gnu': 16.4.0 + '@next/swc-linux-arm64-musl': 16.4.0 + '@next/swc-linux-x64-gnu': 16.4.0 + '@next/swc-linux-x64-musl': 16.4.0 + '@next/swc-win32-arm64-msvc': 16.4.0 + '@next/swc-win32-x64-msvc': 16.4.0 + sharp: 0.35.5(@types/node@22.20.5) + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - babel-plugin-macros + + picocolors@1.1.1: {} + + postcss@8.5.23: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + react-dom@19.3.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + + react@19.3.0: {} + + scheduler@0.28.0: {} + + semver@7.8.5: + optional: true + + server-only@0.0.1: {} + + sharp@0.35.5(@types/node@22.20.5): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 22.20.5 + optional: true + + source-map-js@1.2.2: {} + + styled-jsx@5.1.6(react@19.3.0): + dependencies: + client-only: 0.0.1 + react: 19.3.0 + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + zod@4.6.5: {} diff --git a/packages/entitlements/examples/nextjs/pnpm-workspace.yaml b/packages/entitlements/examples/nextjs/pnpm-workspace.yaml new file mode 100644 index 0000000..5127878 --- /dev/null +++ b/packages/entitlements/examples/nextjs/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +# Standalone install root, separate from the repository's workspace. +packages: + - . +allowBuilds: + sharp: false diff --git a/packages/entitlements/examples/nextjs/src/app/actions.ts b/packages/entitlements/examples/nextjs/src/app/actions.ts new file mode 100644 index 0000000..e9056c0 --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/app/actions.ts @@ -0,0 +1,9 @@ +"use server"; + +import { setSessionUser, USER_FIELD } from "@/lib/session"; + +/** Signs in as the submitted demo user, or out when none is submitted. */ +export async function switchUser(formData: FormData): Promise { + const userId = formData.get(USER_FIELD); + await setSessionUser(typeof userId === "string" ? userId : null); +} diff --git a/packages/entitlements/examples/nextjs/src/app/api/entitlements/route.ts b/packages/entitlements/examples/nextjs/src/app/api/entitlements/route.ts new file mode 100644 index 0000000..ab2776f --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/app/api/entitlements/route.ts @@ -0,0 +1,24 @@ +import { createEntitlementsRelayHandler } from "@chargebee/entitlements/nextjs"; +import { entitlements } from "@/lib/entitlements"; +import { getSessionUser } from "@/lib/session"; + +/** Short, so the browser's refresh is easy to observe. */ +const RELAY_TTL_MS = 15_000; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Serves the session's sanitized snapshot to the browser client. The API key + * and customer ID stay on the server: + * + * browser ─GET /api/entitlements─► relay ─► server client ─► Chargebee + */ +export const GET = createEntitlementsRelayHandler({ + entitlements, + relayTtlMs: RELAY_TTL_MS, + resolveContext: async () => { + const user = await getSessionUser(); + return user ? { customerId: user.chargebeeCustomerId } : null; + }, +}); diff --git a/packages/entitlements/examples/nextjs/src/app/globals.css b/packages/entitlements/examples/nextjs/src/app/globals.css new file mode 100644 index 0000000..1a34aff --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/app/globals.css @@ -0,0 +1,98 @@ +body { + margin: 0; + font-family: system-ui, sans-serif; + color: #1f2328; + background: #f6f8fa; +} + +main { + max-width: 760px; + margin: 0 auto; + padding: 2rem 1rem; +} + +section { + margin-top: 1.5rem; + padding: 1rem 1.25rem; + background: #fff; + border: 1px solid #d0d7de; + border-radius: 8px; +} + +h2 { + margin-top: 0; + font-size: 1.1rem; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 0.4rem 0.5rem; + text-align: left; + border-bottom: 1px solid #eaeef2; +} + +code { + font-size: 0.9em; +} + +button { + margin-right: 0.5rem; + padding: 0.35rem 0.8rem; + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; +} + +.profiles { + margin: 0 0 1rem; + padding: 0; + list-style: none; +} + +.profiles li { + margin-bottom: 0.5rem; + padding: 0.6rem 0.8rem; + border: 1px solid #d0d7de; + border-radius: 6px; +} + +.profiles li.current { + border-color: #1a7f37; + background: #f0fff4; +} + +.profiles p { + margin: 0.25rem 0 0.5rem; +} + +.notice { + padding: 0.6rem 1rem; + background: #fff8c5; + border: 1px solid #d4a72c; + border-radius: 8px; +} + +.muted { + color: #656d76; +} + +.granted { + color: #1a7f37; +} + +.disabled, +.error { + color: #cf222e; +} + +.pending, +.stale { + color: #9a6700; +} diff --git a/packages/entitlements/examples/nextjs/src/app/layout.tsx b/packages/entitlements/examples/nextjs/src/app/layout.tsx new file mode 100644 index 0000000..574ca28 --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/app/layout.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Chargebee entitlements, Next.js example", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + +
    {children}
    + + + ); +} diff --git a/packages/entitlements/examples/nextjs/src/app/page.tsx b/packages/entitlements/examples/nextjs/src/app/page.tsx new file mode 100644 index 0000000..4a98541 --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/app/page.tsx @@ -0,0 +1,93 @@ +import Link from "next/link"; +import { BrowserChecks } from "@/components/browser-checks"; +import { EntitlementsProvider } from "@/components/entitlements-provider"; +import { FeatureTable } from "@/components/feature-table"; +import { UserProfiles } from "@/components/user-profiles"; +import { isMockChargebee } from "@/lib/chargebee"; +import { entitlementsFor } from "@/lib/entitlements"; +import { FEATURES } from "@/lib/features"; +import { getSessionUser, USER_FIELD } from "@/lib/session"; +import { listUsers, type User } from "@/lib/users"; +import { switchUser } from "./actions"; + +/** Every feature, evaluated on the server before the page is sent. */ +async function serverRows(user: User) { + const scoped = entitlementsFor(user); + + return Promise.all( + FEATURES.map(async (feature) => ({ + featureId: feature.featureId, + details: await scoped.getDetails(feature.featureId, feature.defaultValue), + })), + ); +} + +export default async function Home() { + const user = await getSessionUser(); + + return ( + <> +

    Chargebee entitlements

    +

    Next.js example - server and client side checks

    + {isMockChargebee && ( +

    + No Chargebee credentials in .env: answering from the mock + API. Its calls are logged in the terminal. +

    + )} + + {isMockChargebee ? ( +
    +

    Demo users

    + +
    + ) : ( +
    +

    Signed in as

    +
    + {listUsers().map(({ id, name }) => ( + + ))} + +
    +
    + )} + + {user ? ( + <> +
    +

    Server

    + +

    + Rendered by a server component with{" "} + entitlements.for(target). Reload: source{" "} + moves from api to cache. Server-gated + page: /reports. +

    +
    + +
    +

    Browser

    + + + +
    + + ) : ( +

    + Signed out. /api/entitlements answers 401. +

    + )} + + ); +} diff --git a/packages/entitlements/examples/nextjs/src/app/reports/page.tsx b/packages/entitlements/examples/nextjs/src/app/reports/page.tsx new file mode 100644 index 0000000..92e2aa5 --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/app/reports/page.tsx @@ -0,0 +1,34 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { hasFeature } from "@/lib/entitlements"; +import { advancedReports } from "@/lib/features"; +import { getSessionUser } from "@/lib/session"; + +/** Gated on the server, so nothing reaches users without the feature. */ +export default async function ReportsPage() { + const user = await getSessionUser(); + if (!user) { + redirect("/"); + } + + const allowed = await hasFeature(user, advancedReports); + + return ( + <> +

    Advanced reports

    +
    + {allowed ? ( +

    Revenue by region: APAC 1,200.

    + ) : ( +

    + {user.name} has no {advancedReports.featureId}{" "} + entitlement. Upgrade to see this report. +

    + )} +
    +

    + Back +

    + + ); +} diff --git a/packages/entitlements/examples/nextjs/src/components/browser-checks.tsx b/packages/entitlements/examples/nextjs/src/components/browser-checks.tsx new file mode 100644 index 0000000..f4fd14d --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/components/browser-checks.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useReducer, useState } from "react"; +import { + advancedReports, + apiCalls, + licensedSeats, + supportTier, +} from "@/lib/features"; +import { + useEntitlementsClient, + useEntitlementsLoadError, + useFeature, +} from "./entitlements-provider"; +import { FeatureGate } from "./feature-gate"; +import { FeatureTable } from "./feature-table"; + +/** The same features as the server section, read from the relay snapshot. */ +export function BrowserChecks() { + const client = useEntitlementsClient(); + const loadError = useEntitlementsLoadError(); + const [refreshing, setRefreshing] = useState(false); + const [, reevaluate] = useReducer((count: number) => count + 1, 0); + + const reports = useFeature(advancedReports); + const seats = useFeature(licensedSeats); + const calls = useFeature(apiCalls); + const support = useFeature(supportTier); + + const rows = [ + { featureId: advancedReports.featureId, details: reports }, + { featureId: licensedSeats.featureId, details: seats }, + { featureId: apiCalls.featureId, details: calls }, + { featureId: supportTier.featureId, details: support }, + ]; + + // Fetches a fresh snapshot, e.g. after checkout; `onSnapshotRefreshed` re-renders. + const refresh = async () => { + setRefreshing(true); + try { + await client.refreshSnapshot(); + } finally { + setRefreshing(false); + } + }; + + return ( + <> + {loadError && ( +

    {loadError}. Every feature keeps its default.

    + )} + +

    + Upgrade to export reports. } + > + + + + +

    +

    + Export report is shown by{" "} + <FeatureGate feature={advancedReports}>, + and hidden until the snapshot grants it. Re-evaluating reads the + snapshot in memory. After 15 seconds it expires, and the next evaluation + returns stale while the client refetches it (see the + Network tab). +

    + + ); +} diff --git a/packages/entitlements/examples/nextjs/src/components/entitlements-provider.tsx b/packages/entitlements/examples/nextjs/src/components/entitlements-provider.tsx new file mode 100644 index 0000000..647a79e --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/components/entitlements-provider.tsx @@ -0,0 +1,90 @@ +"use client"; + +import type { EntitlementDetails, Feature } from "@chargebee/entitlements"; +import { ChargebeeEntitlements } from "@chargebee/entitlements/web"; +import { + createContext, + type ReactNode, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +const RELAY_URL = "/api/entitlements"; + +interface EntitlementsState { + client: ChargebeeEntitlements; + /** Bumped whenever the snapshot changes, so consumers re-render. */ + version: number; + /** Why the first relay load failed; evaluations stay `pending`. */ + loadError?: string; +} + +const EntitlementsContext = createContext(null); + +/** + * Holds the browser client for the signed-in user. Evaluations are synchronous + * against the relay snapshot; until it loads they return the default with + * status `pending`. Key it by user, so switching users starts from an empty + * snapshot instead of rendering the previous user's. + */ +export function EntitlementsProvider({ children }: { children: ReactNode }) { + const [version, setVersion] = useState(0); + const [loadError, setLoadError] = useState(); + const [client] = useState(() => { + const rerender = () => setVersion((current) => current + 1); + return new ChargebeeEntitlements({ + relayUrl: RELAY_URL, + onSnapshotExpired: rerender, + onSnapshotRefreshed: rerender, + onError: (error) => console.error("[entitlements] refresh failed", error), + }); + }); + + // Closing on unmount discards a relay response still in flight. + useEffect(() => { + client + .initialize() + .then(() => setVersion((current) => current + 1)) + .catch((error: Error) => setLoadError(error.message)); + + return () => { + void client.close(); + }; + }, [client]); + + const state = useMemo( + () => ({ client, version, loadError }), + [client, version, loadError], + ); + + return ( + + {children} + + ); +} + +function useEntitlementsState(): EntitlementsState { + const state = useContext(EntitlementsContext); + if (!state) { + throw new Error("Wrap the component in "); + } + + return state; +} + +export function useEntitlementsClient(): ChargebeeEntitlements { + return useEntitlementsState().client; +} + +export function useEntitlementsLoadError(): string | undefined { + return useEntitlementsState().loadError; +} + +/** Evaluates a shared feature declaration against the browser snapshot. */ +export function useFeature(feature: Feature): EntitlementDetails { + const { client } = useEntitlementsState(); + return client.getDetails(feature.featureId, feature.defaultValue); +} diff --git a/packages/entitlements/examples/nextjs/src/components/feature-gate.tsx b/packages/entitlements/examples/nextjs/src/components/feature-gate.tsx new file mode 100644 index 0000000..3e62d22 --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/components/feature-gate.tsx @@ -0,0 +1,30 @@ +"use client"; + +import type { Feature } from "@chargebee/entitlements"; +import type { ReactNode } from "react"; +import { useFeature } from "./entitlements-provider"; + +/** + * Renders `children` only when the relay snapshot grants `feature`: + * + * ```tsx + * }> + * + * + * ``` + * + * Until the snapshot loads, the feature resolves to its default (`false`), so + * gated content stays hidden rather than flashing in. + */ +export function FeatureGate({ + feature, + fallback = null, + children, +}: { + feature: Feature; + fallback?: ReactNode; + children: ReactNode; +}) { + const { value } = useFeature(feature); + return value ? children : fallback; +} diff --git a/packages/entitlements/examples/nextjs/src/components/feature-table.tsx b/packages/entitlements/examples/nextjs/src/components/feature-table.tsx new file mode 100644 index 0000000..80490fa --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/components/feature-table.tsx @@ -0,0 +1,46 @@ +import type { EntitlementDetails } from "@chargebee/entitlements"; + +export interface FeatureRow { + featureId: string; + details: EntitlementDetails; +} + +/** `Infinity` is how an "unlimited" entitlement resolves for a number feature. */ +function formatValue(value: unknown): string { + if (value === Number.POSITIVE_INFINITY) { + return "unlimited"; + } + + return String(value); +} + +/** Renders on the server and in the browser alike: it has no hooks. */ +export function FeatureTable({ rows }: { rows: FeatureRow[] }) { + return ( + + + + + + + + + + + {rows.map(({ featureId, details }) => ( + + + + + + + ))} + +
    FeatureValueStatusSource
    + {featureId} + {formatValue(details.value)} + {details.status} + {details.error ? ` (${details.error.code})` : ""} + {details.source ?? "none"}
    + ); +} diff --git a/packages/entitlements/examples/nextjs/src/components/user-profiles.tsx b/packages/entitlements/examples/nextjs/src/components/user-profiles.tsx new file mode 100644 index 0000000..b7f3219 --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/components/user-profiles.tsx @@ -0,0 +1,39 @@ +import { switchUser } from "@/app/actions"; +import { USER_FIELD } from "@/lib/session"; +import type { UserProfile } from "@/lib/users"; + +/** Mock-mode switcher: each demo user with what the mock API grants them. */ +export function UserProfiles({ + users, + currentUserId, +}: { + users: UserProfile[]; + currentUserId: string | undefined; +}) { + return ( +
    +
      + {users.map(({ id, name, description }) => { + const current = id === currentUserId; + return ( +
    • + {name} +

      {description}

      + +
    • + ); + })} +
    + +
    + ); +} diff --git a/packages/entitlements/examples/nextjs/src/lib/chargebee.ts b/packages/entitlements/examples/nextjs/src/lib/chargebee.ts new file mode 100644 index 0000000..a97ea06 --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/lib/chargebee.ts @@ -0,0 +1,25 @@ +import "server-only"; + +import Chargebee from "chargebee"; +import { mockHttpClient } from "./mock-chargebee"; + +const MOCK_SITE = "mock-site"; +const MOCK_API_KEY = "mock-api-key"; + +const site = process.env.CHARGEBEE_SITE; +const apiKey = process.env.CHARGEBEE_API_KEY; + +export const isMockChargebee = !site || !apiKey; + +/** Uses your Chargebee site when `.env` has credentials, the mock otherwise. */ +export function createChargebee(): Chargebee { + if (site && apiKey) { + return new Chargebee({ site, apiKey }); + } + + return new Chargebee({ + site: MOCK_SITE, + apiKey: MOCK_API_KEY, + httpClient: mockHttpClient, + }); +} diff --git a/packages/entitlements/examples/nextjs/src/lib/entitlements.ts b/packages/entitlements/examples/nextjs/src/lib/entitlements.ts new file mode 100644 index 0000000..9387a0b --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/lib/entitlements.ts @@ -0,0 +1,56 @@ +import "server-only"; + +import type { Feature } from "@chargebee/entitlements"; +import { createMemoryEntitlementsCache } from "@chargebee/entitlements/cache"; +import { + ChargebeeEntitlements, + type ScopedEntitlements, +} from "@chargebee/entitlements/server"; +import { createChargebee } from "./chargebee"; +import type { User } from "./users"; + +const CACHE_TTL_MS = 30_000; + +/** Chargebee API errors are plain objects with a `message`, not `Error`s. */ +function errorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return (error as { message?: string } | undefined)?.message ?? String(error); +} + +const globalForEntitlements = globalThis as { + chargebeeEntitlements?: ChargebeeEntitlements; +}; + +/** + * One client per process. Kept on `globalThis` so dev hot reloads reuse it + * and its cache instead of refetching from Chargebee. + */ +globalForEntitlements.chargebeeEntitlements ??= new ChargebeeEntitlements({ + chargebeeClient: createChargebee(), + cache: createMemoryEntitlementsCache({ ttlMs: CACHE_TTL_MS }), + // A warning, not `console.error`: Next.js dev shows errors in an overlay, + // and the demo's missing customer fails on purpose. + onError: (error, { operation, target }) => { + console.warn( + `[entitlements] ${operation} failed for ${JSON.stringify(target)}: ${errorMessage(error)}`, + ); + }, +}); + +export const entitlements = globalForEntitlements.chargebeeEntitlements; + +/** The server client bound to the user's Chargebee customer. */ +export function entitlementsFor(user: User): ScopedEntitlements { + return entitlements.for({ customerId: user.chargebeeCustomerId }); +} + +/** Whether the user is entitled to a switch feature. */ +export function hasFeature( + user: User, + feature: Feature, +): Promise { + return entitlementsFor(user).get(feature.featureId, feature.defaultValue); +} diff --git a/packages/entitlements/examples/nextjs/src/lib/features.ts b/packages/entitlements/examples/nextjs/src/lib/features.ts new file mode 100644 index 0000000..f8d3c2b --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/lib/features.ts @@ -0,0 +1,10 @@ +import { Feature } from "@chargebee/entitlements"; + +// Shared by server and browser code. Defaults apply when Chargebee has no +// usable value, so they match the lowest tier. +export const advancedReports = new Feature("advanced-reports", false); +export const licensedSeats = new Feature("licensed-seats", 1); +export const apiCalls = new Feature("api-calls", 100); +export const supportTier = new Feature("support-tier", "community"); + +export const FEATURES = [advancedReports, licensedSeats, apiCalls, supportTier]; diff --git a/packages/entitlements/examples/nextjs/src/lib/mock-chargebee.ts b/packages/entitlements/examples/nextjs/src/lib/mock-chargebee.ts new file mode 100644 index 0000000..dceac90 --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/lib/mock-chargebee.ts @@ -0,0 +1,127 @@ +import type { HttpClientInterface } from "chargebee"; + +/** + * Answers Chargebee API calls with canned data when `.env` has no + * credentials. The real SDK still builds every request; only the HTTP round + * trip is replaced: + * + * ChargebeeEntitlements ─► chargebee SDK ─► mockHttpClient ─► canned JSON + */ + +const LATENCY_MS = 300; +const HTTP_NOT_FOUND = 404; +const CUSTOMER_ENTITLEMENTS_PATH = + /\/customers\/([^/]+)\/customer_entitlements$/; + +/** An entitlement row as the Chargebee API returns it. */ +interface RawEntitlement { + feature_id: string; + feature_name: string; + feature_type: "switch" | "quantity" | "range" | "custom"; + feature_unit?: string; + value: string; + is_enabled: boolean; +} + +const PRO_PLAN: RawEntitlement[] = [ + { + feature_id: "advanced-reports", + feature_name: "Advanced reports", + feature_type: "switch", + value: "true", + is_enabled: true, + }, + { + feature_id: "licensed-seats", + feature_name: "Licensed seats", + feature_type: "quantity", + feature_unit: "seat", + value: "25", + is_enabled: true, + }, + { + feature_id: "api-calls", + feature_name: "API calls per month", + feature_type: "range", + value: "unlimited", + is_enabled: true, + }, + { + feature_id: "support-tier", + feature_name: "Support tier", + feature_type: "custom", + value: "priority", + is_enabled: true, + }, +]; + +/** No `support-tier` row, so that feature resolves to its default. */ +const FREE_PLAN: RawEntitlement[] = [ + { + feature_id: "advanced-reports", + feature_name: "Advanced reports", + feature_type: "switch", + value: "false", + is_enabled: false, + }, + { + feature_id: "licensed-seats", + feature_name: "Licensed seats", + feature_type: "quantity", + feature_unit: "seat", + value: "3", + is_enabled: true, + }, + { + feature_id: "api-calls", + feature_name: "API calls per month", + feature_type: "range", + value: "1000", + is_enabled: true, + }, +]; + +/** Any other customer ID gets a 404, like a customer missing from the site. */ +const CUSTOMERS: Record = { + cust_pro: PRO_PLAN, + cust_free: FREE_PLAN, +}; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Chargebee's error body for an unknown resource. */ +function notFound(message: string): Response { + return Response.json( + { + message, + type: "invalid_request", + api_error_code: "resource_not_found", + http_status_code: HTTP_NOT_FOUND, + }, + { status: HTTP_NOT_FOUND }, + ); +} + +export const mockHttpClient: HttpClientInterface = { + async makeApiRequest(request) { + const { pathname } = new URL(request.url); + console.log(`[mock chargebee] ${request.method} ${pathname}`); + await sleep(LATENCY_MS); + + const match = CUSTOMER_ENTITLEMENTS_PATH.exec(pathname); + if (!match?.[1]) { + return notFound(`No mock for ${pathname}`); + } + + const customerId = decodeURIComponent(match[1]); + const entitlements = CUSTOMERS[customerId]; + if (!entitlements) { + return notFound(`Customer ${customerId} not found`); + } + + // One page: no `next_offset`. + return Response.json({ + list: entitlements.map((row) => ({ customer_entitlement: row })), + }); + }, +}; diff --git a/packages/entitlements/examples/nextjs/src/lib/session.ts b/packages/entitlements/examples/nextjs/src/lib/session.ts new file mode 100644 index 0000000..61aaf96 --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/lib/session.ts @@ -0,0 +1,25 @@ +import "server-only"; + +import { cookies } from "next/headers"; +import { findUser, type User } from "./users"; + +const SESSION_COOKIE = "demo-user"; + +/** Form field the user switcher submits. */ +export const USER_FIELD = "userId"; + +/** Stand-in for real auth: a cookie naming a demo user. */ +export async function getSessionUser(): Promise { + const store = await cookies(); + return findUser(store.get(SESSION_COOKIE)?.value) ?? null; +} + +export async function setSessionUser(userId: string | null): Promise { + const store = await cookies(); + if (!userId) { + store.delete(SESSION_COOKIE); + return; + } + + store.set(SESSION_COOKIE, userId, { httpOnly: true, sameSite: "lax" }); +} diff --git a/packages/entitlements/examples/nextjs/src/lib/users.ts b/packages/entitlements/examples/nextjs/src/lib/users.ts new file mode 100644 index 0000000..443d796 --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/lib/users.ts @@ -0,0 +1,48 @@ +import "server-only"; + +export interface User { + id: string; + name: string; + /** What the mock API grants this user; your own site's data will differ. */ + description: string; + chargebeeCustomerId: string; +} + +export type UserProfile = Omit; + +/** + * Stands in for your user table. With real credentials, point + * `chargebeeCustomerId` at customers in your Chargebee site. + */ +const USERS: User[] = [ + { + id: "alice", + name: "Alice, Pro plan", + description: + "Advanced reports, 25 seats, unlimited API calls, and priority support.", + chargebeeCustomerId: "cust_pro", + }, + { + id: "bob", + name: "Bob, Free plan", + description: + "Advanced reports disabled, 3 seats, and 1,000 API calls. Has no support tier, so it falls back to the default.", + chargebeeCustomerId: "cust_free", + }, + { + id: "carol", + name: "Carol, not a customer", + description: + "Missing from Chargebee. The server falls back to defaults and the relay answers 502.", + chargebeeCustomerId: "cust_missing", + }, +]; + +export function findUser(id: string | undefined): User | undefined { + return USERS.find((user) => user.id === id); +} + +/** Without customer IDs, which never reach the browser. */ +export function listUsers(): UserProfile[] { + return USERS.map(({ id, name, description }) => ({ id, name, description })); +} diff --git a/packages/entitlements/examples/nextjs/tsconfig.json b/packages/entitlements/examples/nextjs/tsconfig.json new file mode 100644 index 0000000..a735b0f --- /dev/null +++ b/packages/entitlements/examples/nextjs/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/packages/entitlements/package.json b/packages/entitlements/package.json new file mode 100644 index 0000000..88f935e --- /dev/null +++ b/packages/entitlements/package.json @@ -0,0 +1,130 @@ +{ + "name": "@chargebee/entitlements", + "author": "DX Chargebee", + "version": "0.1.0", + "type": "module", + "description": "Framework-agnostic Chargebee entitlements resolution", + "license": "MIT", + "homepage": "https://github.com/chargebee/js-framework-adapters/blob/main/packages/entitlements/README.md", + "repository": { + "type": "git", + "url": "git@github.com:chargebee/js-framework-adapters.git", + "directory": "packages/entitlements" + }, + "keywords": [ + "chargebee", + "entitlements", + "feature-flags", + "nextjs" + ], + "engines": { + "node": ">=22" + }, + "main": "dist/index.mjs", + "module": "dist/index.mjs", + "types": "dist/index.d.mts", + "exports": { + ".": { + "dev-source": "./src/index.ts", + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "./cache": { + "dev-source": "./src/cache/index.ts", + "types": "./dist/cache.d.mts", + "default": "./dist/cache.mjs" + }, + "./server": { + "dev-source": "./src/server/index.ts", + "types": "./dist/server.d.mts", + "default": "./dist/server.mjs" + }, + "./web": { + "dev-source": "./src/web/index.ts", + "types": "./dist/web.d.mts", + "default": "./dist/web.mjs" + }, + "./nextjs": { + "dev-source": "./src/nextjs.ts", + "types": "./dist/nextjs.d.mts", + "default": "./dist/nextjs.mjs" + } + }, + "typesVersions": { + "*": { + "*": [ + "./dist/index.d.mts" + ], + "cache": [ + "./dist/cache.d.mts" + ], + "server": [ + "./dist/server.d.mts" + ], + "web": [ + "./dist/web.d.mts" + ], + "nextjs": [ + "./dist/nextjs.d.mts" + ] + } + }, + "files": [ + "dist", + "!dist/src", + "!dist/test", + "!dist/**/*.tsbuildinfo", + "README.md", + "LICENSE.md", + "CHANGELOG.md" + ], + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "typecheck": "tsc --project tsconfig.json && tsc --project bench/tsconfig.json", + "test": "vitest run", + "test:watch": "vitest", + "bench": "vitest bench --run --reporter=default", + "bench:up": "docker compose -f bench/docker-compose.yml up -d --wait", + "bench:down": "docker compose -f bench/docker-compose.yml down -v", + "coverage": "vitest run --coverage", + "lint:package": "publint run --strict", + "lint:types": "attw --profile esm-only --pack ." + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "server-only": "^0.0.1" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", + "@types/node": "22.15.2", + "@types/pg": "^8.23.1", + "@vitest/coverage-v8": "^5.0.5", + "chargebee": "^3.30.0", + "ioredis": "^6.0.0", + "next": "^16.3.0", + "pg": "^8.23.1", + "publint": "^0.3.23", + "tsdown": "^0.20.3", + "typescript": "^5.9.2", + "vitest": "^5.0.5" + }, + "peerDependencies": { + "chargebee": "^3.30.0", + "ioredis": "^6.0.0", + "next": "^16.0.0" + }, + "peerDependenciesMeta": { + "chargebee": { + "optional": false + }, + "ioredis": { + "optional": true + }, + "next": { + "optional": true + } + } +} diff --git a/packages/entitlements/src/cache/index.ts b/packages/entitlements/src/cache/index.ts new file mode 100644 index 0000000..2e542d3 --- /dev/null +++ b/packages/entitlements/src/cache/index.ts @@ -0,0 +1,18 @@ +export type { ChargebeeEntitlementsSnapshot } from "../shared"; +export { + parseSerializedEntitlementsSnapshot, + serializeEntitlementsSnapshot, +} from "../shared"; +export { createEntitlementsCacheKey } from "./key"; +export { + createMemoryEntitlementsCache, + type MemoryEntitlementsCacheOptions, +} from "./memory"; +export { + createRedisEntitlementsCache, + type RedisEntitlementsCacheOptions, +} from "./redis"; +export type { + EntitlementsStorage, + RedisEntitlementsCacheClient, +} from "./types"; diff --git a/packages/entitlements/src/cache/key.ts b/packages/entitlements/src/cache/key.ts new file mode 100644 index 0000000..8e67d62 --- /dev/null +++ b/packages/entitlements/src/cache/key.ts @@ -0,0 +1,20 @@ +import type { ChargebeeTarget } from "../shared"; + +export function createEntitlementsCacheKey( + target: ChargebeeTarget, + options: { + namespace?: string; + consolidateCustomerEntitlements?: boolean; + } = {}, +): string { + const namespace = options.namespace ?? "chargebee:entitlements:v1"; + if (target.subscriptionId !== undefined) { + return `${namespace}:subscription:${encodeURIComponent(target.subscriptionId)}`; + } + + const view = + (options.consolidateCustomerEntitlements ?? true) + ? "consolidated" + : "individual"; + return `${namespace}:customer:${encodeURIComponent(target.customerId)}:${view}`; +} diff --git a/packages/entitlements/src/cache/memory.ts b/packages/entitlements/src/cache/memory.ts new file mode 100644 index 0000000..86a90fb --- /dev/null +++ b/packages/entitlements/src/cache/memory.ts @@ -0,0 +1,70 @@ +import type { ChargebeeEntitlementsSnapshot } from "../shared"; +import type { EntitlementsStorage } from "./types"; + +export interface MemoryEntitlementsCacheOptions { + maxEntries?: number; + /** Expiry applied when `set` is called without one. Defaults to 60s. */ + ttlMs?: number; + now?: () => number; +} + +interface MemoryCacheEntry { + snapshot: ChargebeeEntitlementsSnapshot; + expiresAt: number; +} + +export function createMemoryEntitlementsCache( + options: MemoryEntitlementsCacheOptions = {}, +): EntitlementsStorage { + const maxEntries = options.maxEntries ?? 500; + const ttlMs = options.ttlMs ?? 60_000; + const now = options.now ?? Date.now; + if (!Number.isInteger(maxEntries) || maxEntries < 1) { + throw new Error("maxEntries must be a positive integer"); + } + if (!Number.isFinite(ttlMs) || ttlMs <= 0) { + throw new Error("ttlMs must be a positive number"); + } + + const entries = new Map(); + + return { + async get(key) { + const entry = entries.get(key); + if (!entry) return undefined; + + if (entry.expiresAt <= now()) { + entries.delete(key); + return undefined; + } + + // Moving a hit to the end makes Map insertion order an LRU list. + entries.delete(key); + entries.set(key, entry); + return entry.snapshot; + }, + + async set(key, snapshot, entryTtlMs = ttlMs) { + if (entryTtlMs <= 0) return; + entries.delete(key); + entries.set(key, { + snapshot, + expiresAt: now() + entryTtlMs, + }); + + while (entries.size > maxEntries) { + const oldestKey = entries.keys().next().value; + if (oldestKey === undefined) return; + entries.delete(oldestKey); + } + }, + + async delete(key) { + entries.delete(key); + }, + + async clear() { + entries.clear(); + }, + }; +} diff --git a/packages/entitlements/src/cache/redis.ts b/packages/entitlements/src/cache/redis.ts new file mode 100644 index 0000000..1080bfc --- /dev/null +++ b/packages/entitlements/src/cache/redis.ts @@ -0,0 +1,44 @@ +import { + parseSerializedEntitlementsSnapshot, + serializeEntitlementsSnapshot, +} from "../shared"; +import type { + EntitlementsStorage, + RedisEntitlementsCacheClient, +} from "./types"; + +export interface RedisEntitlementsCacheOptions { + /** Expiry applied when `set` is called without one. Defaults to 60s. */ + ttlMs?: number; +} + +export function createRedisEntitlementsCache( + client: RedisEntitlementsCacheClient, + options: RedisEntitlementsCacheOptions = {}, +): EntitlementsStorage { + const defaultTtlMs = options.ttlMs ?? 60_000; + if (!Number.isFinite(defaultTtlMs) || defaultTtlMs <= 0) { + throw new Error("ttlMs must be a positive number"); + } + + return { + async get(key) { + const value = await client.get(key); + if (value == null) return undefined; + + try { + return parseSerializedEntitlementsSnapshot(value); + } catch { + // Corrupt or old cache values are treated as misses. + await client.del(key); + return undefined; + } + }, + async set(key, value, ttlMs = defaultTtlMs) { + await client.set(key, serializeEntitlementsSnapshot(value), "PX", ttlMs); + }, + async delete(key) { + await client.del(key); + }, + }; +} diff --git a/packages/entitlements/src/cache/types.ts b/packages/entitlements/src/cache/types.ts new file mode 100644 index 0000000..8acbcd4 --- /dev/null +++ b/packages/entitlements/src/cache/types.ts @@ -0,0 +1,24 @@ +import type Redis from "ioredis"; +import type { ChargebeeEntitlementsSnapshot } from "../shared"; + +/** + * Storage contract shared by the fast shared cache and the durable snapshot + * store. Implement it over Redis, PostgreSQL, or any other backend. + */ +export interface EntitlementsStorage { + get(key: string): Promise; + /** + * Stores a snapshot. `ttlMs` is the caller's requested expiry; an + * implementation configured with its own TTL applies that when omitted. + */ + set( + key: string, + value: ChargebeeEntitlementsSnapshot, + ttlMs?: number, + ): Promise; + delete(key: string): Promise; + clear?(): Promise; +} + +/** The subset of an ioredis `Redis` client that `createRedisEntitlementsCache` needs. */ +export type RedisEntitlementsCacheClient = Pick; diff --git a/packages/entitlements/src/index.ts b/packages/entitlements/src/index.ts new file mode 100644 index 0000000..c558988 --- /dev/null +++ b/packages/entitlements/src/index.ts @@ -0,0 +1,17 @@ +export type { + ChargebeeEntitlement, + ChargebeeTarget, + EntitlementDetails, + EntitlementError, + EntitlementErrorCode, + EntitlementStatus, + Logger, + SnapshotSource, +} from "./shared"; +export { + type EntitlementsClient, + Feature, + type ScopedEntitlementsClient, + setDefaultEntitlements, + type TargetedEntitlementsClient, +} from "./shared"; diff --git a/packages/entitlements/src/nextjs.ts b/packages/entitlements/src/nextjs.ts new file mode 100644 index 0000000..b06c250 --- /dev/null +++ b/packages/entitlements/src/nextjs.ts @@ -0,0 +1,21 @@ +import "server-only"; + +import type { NextRequest } from "next/server"; +import { + type CreateEntitlementsRelayHandlerOptions, + createEntitlementsRelayHandler as createRelayHandler, +} from "./server/relay"; + +/** + * Builds an App Router `GET` handler that resolves billing identity on the + * server and returns a sanitized entitlement snapshot for a browser client. + * This is the Next.js `NextRequest` instantiation of + * `createEntitlementsRelayHandler` from `@chargebee/entitlements/server`. + */ +export function createEntitlementsRelayHandler( + options: CreateEntitlementsRelayHandlerOptions, +): (request: NextRequest) => Promise { + return createRelayHandler(options); +} + +export type { CreateEntitlementsRelayHandlerOptions }; diff --git a/packages/entitlements/src/server/entitlements.ts b/packages/entitlements/src/server/entitlements.ts new file mode 100644 index 0000000..e4776cb --- /dev/null +++ b/packages/entitlements/src/server/entitlements.ts @@ -0,0 +1,540 @@ +import { createEntitlementsCacheKey, type EntitlementsStorage } from "../cache"; +import { + assertTarget, + type ChargebeeEntitlementsSnapshot, + type ChargebeeTarget, + createEntitlementsSnapshot, + type EntitlementDetails, + errorDetails, + Feature, + isSnapshotExpired, + type Logger, + resolveEntitlement, + type SnapshotSource, + type TargetedEntitlementsClient, +} from "../shared"; +import { + type ChargebeeEntitlementsClient, + ChargebeeEntitlementsLoader, +} from "./loader"; +import { ScopedEntitlements } from "./scoped"; + +type SnapshotOperation = + | "cache-read" + | "cache-write" + | "cache-delete" + | "store-read" + | "store-write" + | "store-delete" + | "refresh"; + +export interface SnapshotErrorInfo { + operation: SnapshotOperation; + target: ChargebeeTarget; +} + +export interface SnapshotRefreshedEvent { + target: ChargebeeTarget; + snapshot: ChargebeeEntitlementsSnapshot; + /** `explicit` for `refreshSnapshot` calls, `request` for request refreshes. */ + trigger: "explicit" | "request"; +} + +export interface ChargebeeEntitlementsOptions { + chargebeeClient: ChargebeeEntitlementsClient; + /** Fast shared cache (Redis or similar) read before the durable store. */ + cache?: EntitlementsStorage; + /** Cache expiry. Defaults to whatever the cache implementation applies. */ + cacheTtlMs?: number; + /** Durable snapshot store treated as the source of truth. */ + durableStore?: EntitlementsStorage; + /** How long a snapshot is valid before it is considered expired. Defaults to 300 000 ms. */ + snapshotTtlMs?: number; + /** + * What happens when neither the cache nor the store holds a snapshot. + * `blocking` waits for Chargebee; `background` starts the refresh and + * reports the snapshot as pending so callers fall back to their defaults. + */ + refreshOnMiss?: "blocking" | "background"; + logger?: Logger; + onSnapshotRefreshed?: (event: SnapshotRefreshedEvent) => void; + onError?: (error: unknown, info: SnapshotErrorInfo) => void; + /** Rarely-needed options. */ + advanced?: { + cacheNamespace?: string; + consolidateCustomerEntitlements?: boolean; + /** Minimum gap between background refresh attempts after a failure. */ + refreshBackoffMs?: number; + pageSize?: number; + maxPages?: number; + }; +} + +export interface EntitlementsSnapshotResult { + snapshot: ChargebeeEntitlementsSnapshot; + source: Exclude; +} + +class InvalidEntitlementContextError extends Error {} + +/** + * Thrown when no snapshot is available locally and the client was configured + * to refresh in the background instead of blocking on Chargebee. + */ +export class SnapshotPendingError extends Error { + readonly name = "SnapshotPendingError"; + + constructor(readonly target: ChargebeeTarget) { + super("Chargebee entitlement snapshot is still loading"); + } +} + +/** + * Framework-agnostic Chargebee entitlements client for servers. Resolves a + * snapshot from a shared cache, a durable store, or the Chargebee API, and + * evaluates feature IDs against it. + */ +export class ChargebeeEntitlements { + private readonly consolidateCustomerEntitlements: boolean; + private readonly cache?: EntitlementsStorage; + private readonly cacheTtlMs?: number; + private readonly durableStore?: EntitlementsStorage; + private readonly cacheNamespace?: string; + private readonly snapshotTtlMs: number; + private readonly refreshOnMiss: "blocking" | "background"; + private readonly refreshBackoffMs: number; + private readonly onSnapshotRefreshed?: ( + event: SnapshotRefreshedEvent, + ) => void; + private readonly onError?: (error: unknown, info: SnapshotErrorInfo) => void; + private readonly loader: ChargebeeEntitlementsLoader; + private readonly inFlight = new Map< + string, + { promise: Promise; cancelled: boolean } + >(); + private readonly failedAt = new Map(); + + constructor(options: ChargebeeEntitlementsOptions) { + if (!options?.chargebeeClient) + throw new Error("chargebeeClient is required"); + + this.consolidateCustomerEntitlements = + options.advanced?.consolidateCustomerEntitlements ?? true; + this.cache = options.cache; + this.cacheTtlMs = options.cacheTtlMs; + this.durableStore = options.durableStore; + this.cacheNamespace = options.advanced?.cacheNamespace; + this.snapshotTtlMs = options.snapshotTtlMs ?? 300_000; + this.refreshOnMiss = options.refreshOnMiss ?? "blocking"; + this.refreshBackoffMs = options.advanced?.refreshBackoffMs ?? 10_000; + this.onSnapshotRefreshed = options.onSnapshotRefreshed; + this.onError = options.onError; + for (const [name, value] of [ + ["cacheTtlMs", this.cacheTtlMs], + ["snapshotTtlMs", this.snapshotTtlMs], + ] as const) { + if (value !== undefined && (!Number.isFinite(value) || value <= 0)) { + throw new Error(`${name} must be a positive number`); + } + } + this.loader = new ChargebeeEntitlementsLoader({ + chargebeeClient: options.chargebeeClient, + consolidateCustomerEntitlements: this.consolidateCustomerEntitlements, + pageSize: options.advanced?.pageSize ?? 100, + maxPages: options.advanced?.maxPages ?? 50, + logger: options.logger, + }); + } + + async close(): Promise { + for (const request of this.inFlight.values()) request.cancelled = true; + this.inFlight.clear(); + this.failedAt.clear(); + } + + /** + * Resolves a feature into whatever shape `defaultValue` declares, and falls + * back to that default when Chargebee has no usable value. + */ + async get( + featureId: string, + defaultValue: T, + target: ChargebeeTarget, + ): Promise { + return (await this.getDetails(featureId, defaultValue, target)).value; + } + + /** Like {@link get}, but returns the status and entitlement behind the value. */ + getDetails( + featureId: string, + defaultValue: T, + target: ChargebeeTarget, + ): Promise> { + return this.evaluate( + defaultValue, + () => this.getSnapshot(target), + (result) => + resolveEntitlement( + result.snapshot, + featureId, + defaultValue, + result.source, + ), + ); + } + + /** + * Like {@link get}, but skips the cache and store and reads Chargebee, for + * critical flows such as checkout. The result is written to the store and + * the cache entry is evicted, so later reads pick it up. + */ + async getFromAPI( + featureId: string, + defaultValue: T, + target: ChargebeeTarget, + ): Promise { + const details = await this.evaluate( + defaultValue, + () => this.fetchFromAPI(target), + (result) => + resolveEntitlement( + result.snapshot, + featureId, + defaultValue, + result.source, + ), + ); + return details.value; + } + + /** + * Binds this client to one target, e.g. once per request, so evaluations + * read like the browser client's: `entitlements.for(target).get(id, 0)`. + */ + for(target: ChargebeeTarget): ScopedEntitlements { + return new ScopedEntitlements(this, target); + } + + /** + * Declares a feature bound to this client, so its value is fetched with a + * single concise call: + * + * ```ts + * const seats = entitlements.feature("licensed-seats", 0); + * + * const count = await seats.get({ customerId }); + * ``` + */ + feature( + featureId: string, + defaultValue: T, + ): Feature { + return new Feature( + featureId, + defaultValue, + this, + ); + } + + /** + * Resolves a snapshot from the cache, then the store, then Chargebee. + * Throws {@link SnapshotPendingError} when nothing is stored locally and + * `refreshOnMiss` is `background`. + */ + async getSnapshot( + target: ChargebeeTarget, + ): Promise { + const resolved = this.resolveTarget(target); + const key = this.cacheKey(resolved); + + const cached = await this.read(this.cache, key, resolved, "cache-read"); + if (cached) { + this.refreshIfExpired(cached, resolved, key); + return { snapshot: cached, source: "cache" }; + } + + const stored = await this.read( + this.durableStore, + key, + resolved, + "store-read", + ); + if (stored) { + await this.write(this.cache, key, stored, resolved, "cache-write"); + this.refreshIfExpired(stored, resolved, key); + return { snapshot: stored, source: "store" }; + } + + if (this.refreshOnMiss === "background") { + this.scheduleRefresh(resolved, key); + throw new SnapshotPendingError(resolved); + } + + return { + snapshot: await this.fetchAndPersistSnapshot(resolved, key, "request"), + source: "api", + }; + } + + /** + * Fetches the complete snapshot from Chargebee and writes it to the store + * and cache. Use this from webhook workers and reconciliation jobs. + * + * The cache entry is dropped before the fetch: an explicit refresh means the + * truth has changed, so reads fall through to the store rather than serve a + * value the caller already knows is stale. + */ + async refreshSnapshot( + target: ChargebeeTarget, + ): Promise { + const resolved = this.resolveTarget(target); + const key = this.cacheKey(resolved); + // An explicit refresh represents a known upstream change (typically a + // webhook). It must not join a request refresh that may have started + // before that change. Cancel both before and after the async eviction so + // no refresh started during that gap can be reused either. + this.cancelInFlight(key); + await this.evictCache(key, resolved); + this.cancelInFlight(key); + return { + snapshot: await this.fetchAndPersistSnapshot(resolved, key, "explicit"), + source: "api", + }; + } + + /** Writes a snapshot assembled elsewhere, e.g. from a webhook payload. */ + async writeSnapshot( + target: ChargebeeTarget, + snapshot: ChargebeeEntitlementsSnapshot, + ): Promise { + const resolved = this.resolveTarget(target); + const key = this.cacheKey(resolved); + this.cancelInFlight(key); + await this.persist(key, snapshot, resolved); + } + + /** Removes the snapshot from both the cache and the durable store. */ + async deleteSnapshot(target: ChargebeeTarget): Promise { + const resolved = this.resolveTarget(target); + const key = this.cacheKey(resolved); + this.cancelInFlight(key); + await this.evictCache(key, resolved); + await this.durableStore?.delete(key); + } + + /** + * The server already decided this snapshot is servable (an expired store + * copy outlives Chargebee outages), so the browser trusts it for `ttlMs` + * instead of inheriting a past `expiresAt` and refetching on every check. + */ + async getRelaySnapshot( + target: ChargebeeTarget, + ttlMs = 60_000, + ): Promise { + const { snapshot } = await this.getSnapshot(target); + return { + ...snapshot, + expiresAt: new Date(Date.now() + ttlMs).toISOString(), + }; + } + + private async evaluate( + defaultValue: T, + load: () => Promise, + resolve: (result: EntitlementsSnapshotResult) => EntitlementDetails, + ): Promise> { + try { + return resolve(await load()); + } catch (error) { + if (error instanceof SnapshotPendingError) { + return { value: defaultValue, status: "pending" }; + } + return errorDetails( + defaultValue, + error instanceof InvalidEntitlementContextError + ? "invalid-target" + : "unavailable", + error instanceof Error + ? error.message + : "Unable to evaluate Chargebee entitlement", + ); + } + } + + private safeStorageOp( + storage: EntitlementsStorage | undefined, + operation: SnapshotOperation, + target: ChargebeeTarget, + action: (store: EntitlementsStorage) => Promise, + ): Promise { + if (!storage) return Promise.resolve(undefined); + return action(storage).catch((error) => { + this.onError?.(error, { operation, target }); + return undefined; + }); + } + + private read( + storage: EntitlementsStorage | undefined, + key: string, + target: ChargebeeTarget, + operation: Extract, + ): Promise { + return this.safeStorageOp(storage, operation, target, (store) => + store.get(key), + ); + } + + private async write( + storage: EntitlementsStorage | undefined, + key: string, + snapshot: ChargebeeEntitlementsSnapshot, + target: ChargebeeTarget, + operation: Extract, + ): Promise { + await this.safeStorageOp(storage, operation, target, (store) => + store.set( + key, + snapshot, + operation === "cache-write" ? this.cacheTtlMs : this.snapshotTtlMs, + ), + ); + } + + private async evictCache( + key: string, + target: ChargebeeTarget, + ): Promise { + await this.safeStorageOp(this.cache, "cache-delete", target, (store) => + store.delete(key), + ); + } + + /** + * The store is authoritative, so an expired snapshot is still served while + * a fresh copy loads in the background. + */ + private refreshIfExpired( + snapshot: ChargebeeEntitlementsSnapshot, + target: ChargebeeTarget, + key: string, + ): void { + if (isSnapshotExpired(snapshot)) this.scheduleRefresh(target, key); + } + + private scheduleRefresh(target: ChargebeeTarget, key: string): void { + const failedAt = this.failedAt.get(key); + if (failedAt !== undefined && Date.now() - failedAt < this.refreshBackoffMs) + return; + + void this.fetchAndPersistSnapshot(target, key, "request").catch(() => { + // Reported through onError inside fetchAndPersistSnapshot. + }); + } + + /** + * A refresh already in flight may predate the change the caller cares + * about, so it is cancelled rather than joined; otherwise it would + * overwrite this result when it lands. + */ + private async fetchFromAPI( + target: ChargebeeTarget, + ): Promise { + const resolved = this.resolveTarget(target); + const key = this.cacheKey(resolved); + this.cancelInFlight(key); + + let snapshot: ChargebeeEntitlementsSnapshot; + try { + snapshot = createEntitlementsSnapshot( + await this.loader.load(resolved), + this.snapshotTtlMs, + ); + } catch (error) { + this.onError?.(error, { operation: "refresh", target: resolved }); + throw error; + } + + // Store first, then evict: a reader that misses the cache must find the + // new snapshot in the store, not refill the cache with the old one. + await this.write(this.durableStore, key, snapshot, resolved, "store-write"); + await this.evictCache(key, resolved); + this.onSnapshotRefreshed?.({ + target: resolved, + snapshot, + trigger: "request", + }); + return { snapshot, source: "api" }; + } + + private fetchAndPersistSnapshot( + target: ChargebeeTarget, + key: string, + trigger: SnapshotRefreshedEvent["trigger"], + ): Promise { + const current = this.inFlight.get(key); + if (current) return current.promise; + + const request = { + cancelled: false, + promise: Promise.resolve().then(async () => { + try { + const snapshot = createEntitlementsSnapshot( + await this.loader.load(target), + this.snapshotTtlMs, + ); + if (!request.cancelled) { + await this.persist(key, snapshot, target); + this.onSnapshotRefreshed?.({ target, snapshot, trigger }); + } + this.failedAt.delete(key); + return snapshot; + } catch (error) { + this.failedAt.set(key, Date.now()); + this.onError?.(error, { operation: "refresh", target }); + throw error; + } finally { + if (this.inFlight.get(key) === request) this.inFlight.delete(key); + } + }), + }; + this.inFlight.set(key, request); + return request.promise; + } + + /** The durable store is the source of truth, so its write failures propagate. */ + private async persist( + key: string, + snapshot: ChargebeeEntitlementsSnapshot, + target: ChargebeeTarget, + ): Promise { + if (this.durableStore) + await this.durableStore.set(key, snapshot, this.snapshotTtlMs); + await this.write(this.cache, key, snapshot, target, "cache-write"); + } + + private cancelInFlight(key: string): void { + const current = this.inFlight.get(key); + if (current) current.cancelled = true; + this.inFlight.delete(key); + } + + private resolveTarget(target: ChargebeeTarget): ChargebeeTarget { + try { + return assertTarget(target); + } catch (error) { + throw new InvalidEntitlementContextError( + error instanceof Error ? error.message : "Invalid Chargebee target", + ); + } + } + + private cacheKey(target: ChargebeeTarget): string { + return createEntitlementsCacheKey(target, { + namespace: this.cacheNamespace, + consolidateCustomerEntitlements: this.consolidateCustomerEntitlements, + }); + } +} + +export type { ChargebeeEntitlementsClient }; diff --git a/packages/entitlements/src/server/index.ts b/packages/entitlements/src/server/index.ts new file mode 100644 index 0000000..fb742da --- /dev/null +++ b/packages/entitlements/src/server/index.ts @@ -0,0 +1,13 @@ +export { createEntitlementsSnapshot } from "../shared"; +export { + ChargebeeEntitlements, + type ChargebeeEntitlementsOptions, + type SnapshotErrorInfo, + SnapshotPendingError, + type SnapshotRefreshedEvent, +} from "./entitlements"; +export { + type CreateEntitlementsRelayHandlerOptions, + createEntitlementsRelayHandler, +} from "./relay"; +export type { ScopedEntitlements } from "./scoped"; diff --git a/packages/entitlements/src/server/loader.ts b/packages/entitlements/src/server/loader.ts new file mode 100644 index 0000000..197506e --- /dev/null +++ b/packages/entitlements/src/server/loader.ts @@ -0,0 +1,128 @@ +import type Chargebee from "chargebee"; +import type { CustomerEntitlement, SubscriptionEntitlement } from "chargebee"; +import type { ChargebeeEntitlement, ChargebeeTarget, Logger } from "../shared"; + +export type ChargebeeEntitlementsClient = Pick< + Chargebee, + "customerEntitlement" | "subscriptionEntitlement" +>; + +interface EntitlementsPage { + list: T[]; + next_offset?: string; +} + +interface EntitlementsLoaderOptions { + chargebeeClient: ChargebeeEntitlementsClient; + consolidateCustomerEntitlements: boolean; + pageSize: number; + maxPages: number; + logger?: Logger; +} + +type RawEntitlement = Partial; + +function withoutUndefined(value: T): T { + return Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined), + ) as T; +} + +function normalizeEntitlement( + entitlement: RawEntitlement, +): ChargebeeEntitlement | undefined { + if (!entitlement.feature_id) return undefined; + return withoutUndefined({ + featureId: entitlement.feature_id, + isEnabled: entitlement.is_enabled ?? false, + isOverridden: entitlement.is_overridden, + value: entitlement.value, + name: entitlement.name, + featureName: entitlement.feature_name, + featureUnit: entitlement.feature_unit, + featureType: entitlement.feature_type, + expiresAt: entitlement.expires_at, + }); +} + +export class ChargebeeEntitlementsLoader { + constructor(private readonly options: EntitlementsLoaderOptions) { + if ( + !Number.isInteger(options.pageSize) || + options.pageSize < 1 || + options.pageSize > 100 + ) { + throw new Error("pageSize must be an integer between 1 and 100"); + } + if (!Number.isInteger(options.maxPages) || options.maxPages < 1) { + throw new Error("maxPages must be a positive integer"); + } + } + + load(target: ChargebeeTarget): Promise { + const normalize = (item: { + customer_entitlement?: CustomerEntitlement; + subscription_entitlement?: SubscriptionEntitlement; + }) => + normalizeEntitlement( + item.customer_entitlement ?? item.subscription_entitlement ?? {}, + ); + + if (target.subscriptionId !== undefined) { + const { subscriptionId } = target; + return this.collect( + (offset) => + this.options.chargebeeClient.subscriptionEntitlement.subscriptionEntitlementsForSubscription( + subscriptionId, + { limit: this.options.pageSize, offset }, + ), + normalize, + "subscription", + ); + } + + const { customerId } = target; + return this.collect( + (offset) => + this.options.chargebeeClient.customerEntitlement.entitlementsForCustomer( + customerId, + { + limit: this.options.pageSize, + offset, + consolidate_entitlements: + this.options.consolidateCustomerEntitlements, + }, + ), + normalize, + "customer", + ); + } + + private async collect( + loadPage: (offset?: string) => Promise>, + normalize: (item: T) => ChargebeeEntitlement | undefined, + scope: "customer" | "subscription", + ): Promise { + const entitlements: ChargebeeEntitlement[] = []; + let offset: string | undefined; + + for (let page = 0; page < this.options.maxPages; page += 1) { + const response = await loadPage(offset); + for (const item of response.list) { + const entitlement = normalize(item); + if (entitlement) entitlements.push(entitlement); + else + this.options.logger?.warn( + `Chargebee ${scope} entitlement omitted a feature_id`, + ); + } + + offset = response.next_offset; + if (!offset) return entitlements; + } + + throw new Error( + `Chargebee ${scope} entitlement pagination exceeded ${this.options.maxPages} pages`, + ); + } +} diff --git a/packages/entitlements/src/server/relay.ts b/packages/entitlements/src/server/relay.ts new file mode 100644 index 0000000..d38ccba --- /dev/null +++ b/packages/entitlements/src/server/relay.ts @@ -0,0 +1,83 @@ +import type { ChargebeeEntitlementsSnapshot, ChargebeeTarget } from "../shared"; + +export type EntitlementsRelayHandler = ( + request: TRequest, +) => Promise; + +/** The subset of `ChargebeeEntitlements` (or an adapter over it) the relay needs. */ +export interface EntitlementsRelaySource { + getRelaySnapshot( + target: ChargebeeTarget, + ttlMs?: number, + ): Promise; +} + +export interface CreateEntitlementsRelayHandlerOptions< + TRequest extends Request = Request, +> { + entitlements: EntitlementsRelaySource; + /** + * Derives the billing target from the authenticated server session. Return + * `null` to respond 401. + */ + resolveContext: ( + request: TRequest, + ) => ChargebeeTarget | null | Promise; + /** How long the browser client should trust the snapshot before re-fetching. */ + relayTtlMs?: number; + onError?: (error: unknown, request: TRequest) => Response | Promise; +} + +/** + * Billing identity comes from the server session, never the request. A client + * that supplies either identifier is rejected outright rather than ignored. + */ +const IDENTITY_PARAMS = ["customerId", "subscriptionId"] as const; + +const responseHeaders = { + "Cache-Control": "private, no-cache, no-store, max-age=0, must-revalidate", + "Content-Type": "application/json", +} as const; + +const json = ( + body: unknown, + status: number, + headers: HeadersInit = responseHeaders, +) => Response.json(body, { status, headers }); + +export function createEntitlementsRelayHandler< + TRequest extends Request = Request, +>( + options: CreateEntitlementsRelayHandlerOptions, +): EntitlementsRelayHandler { + return async (request) => { + try { + if (request.method !== "GET") { + return json({ error: "Method not allowed" }, 405, { + ...responseHeaders, + Allow: "GET", + }); + } + + const search = new URL(request.url).searchParams; + if (IDENTITY_PARAMS.some((key) => search.has(key))) { + return json( + { error: "Billing identity must be resolved by the server" }, + 400, + ); + } + + const target = await options.resolveContext(request); + if (!target) return json({ error: "Unauthorized" }, 401); + + return json( + await options.entitlements.getRelaySnapshot(target, options.relayTtlMs), + 200, + ); + } catch (error) { + return options.onError + ? options.onError(error, request) + : json({ error: "Unable to load entitlements" }, 502); + } + }; +} diff --git a/packages/entitlements/src/server/scoped.ts b/packages/entitlements/src/server/scoped.ts new file mode 100644 index 0000000..148ff49 --- /dev/null +++ b/packages/entitlements/src/server/scoped.ts @@ -0,0 +1,52 @@ +import { + type ChargebeeTarget, + type EntitlementDetails, + Feature, + type ScopedEntitlementsClient, + type TargetedEntitlementsClient, +} from "../shared"; + +/** + * A server client bound to one target, so call sites read like the browser + * client's: + * + * ```ts + * const scoped = entitlements.for({ customerId }); + * + * await scoped.get("licensed-seats", 0); + * ``` + * + * Cheap enough to create per request: it holds no state of its own and + * shares the parent's cache, store, and in-flight refreshes. The target is + * validated on each evaluation, like the parent's. + */ +export class ScopedEntitlements implements ScopedEntitlementsClient { + constructor( + private readonly client: TargetedEntitlementsClient, + private readonly target: ChargebeeTarget, + ) {} + + async get(featureId: string, defaultValue: T): Promise { + return (await this.getDetails(featureId, defaultValue)).value; + } + + /** Like {@link get}, but returns the status and entitlement behind the value. */ + getDetails( + featureId: string, + defaultValue: T, + ): Promise> { + return this.client.getDetails(featureId, defaultValue, this.target); + } + + /** Declares a feature bound to this target; its `get` takes no arguments. */ + feature( + featureId: string, + defaultValue: T, + ): Feature { + return new Feature( + featureId, + defaultValue, + this, + ); + } +} diff --git a/packages/entitlements/src/shared/evaluation.ts b/packages/entitlements/src/shared/evaluation.ts new file mode 100644 index 0000000..b51e1f3 --- /dev/null +++ b/packages/entitlements/src/shared/evaluation.ts @@ -0,0 +1,152 @@ +import type { + ChargebeeEntitlement, + ChargebeeEntitlementsSnapshot, + EntitlementDetails, + EntitlementError, + EntitlementErrorCode, + SnapshotSource, +} from "./types"; + +/** The value shapes a Chargebee entitlement can be read as. */ +type ValueKind = "boolean" | "string" | "number" | "object"; + +type ParsedValue = { value: unknown } | { error: EntitlementError }; + +const BOOLEAN_VALUES = new Set(["true", "false", "available"]); +const TRUE_VALUES = new Set(["true", "available"]); +const UNLIMITED = "unlimited"; +const MS_PER_SECOND = 1000; + +/** + * The shape to parse an entitlement into. Chargebee stores every value as a + * string, so something has to decide whether `"42"` is a number or a string, + * and TypeScript generics are gone by the time this runs. The default value + * answers it: because it is typed as `T`, its runtime type is always the + * declared one. + */ +function kindOf(defaultValue: unknown): ValueKind { + const kind = typeof defaultValue; + return kind === "boolean" || kind === "string" || kind === "number" + ? kind + : "object"; +} + +export function errorDetails( + value: T, + code: EntitlementErrorCode, + message: string, +): EntitlementDetails { + return { value, status: "error", error: { code, message } }; +} + +/** Disabled and expired (`expiresAt` is in Unix seconds) entitlements grant nothing. */ +function isActive(entitlement: ChargebeeEntitlement): boolean { + if (!entitlement.isEnabled) { + return false; + } + + return ( + entitlement.expiresAt === undefined || + entitlement.expiresAt * MS_PER_SECOND > Date.now() + ); +} + +const mismatch = (featureId: string, kind: ValueKind): ParsedValue => ({ + error: { + code: "type-mismatch", + message: `Chargebee feature ${featureId} is not a ${kind} entitlement`, + }, +}); + +/** + * Reads an entitlement's string value as `kind`, e.g. for `kind` `number`: + * `"25"` → `25`, `"unlimited"` → `Infinity`, `"priority"` → type mismatch. + */ +function parseValue( + entitlement: ChargebeeEntitlement, + kind: ValueKind, + featureId: string, +): ParsedValue { + switch (kind) { + case "boolean": { + const normalized = entitlement.value?.trim().toLowerCase(); + + // A switch granted without a value is simply on. + const bareSwitch = + entitlement.featureType === undefined || + entitlement.featureType === "switch"; + if (!normalized && bareSwitch) { + return { value: true }; + } + + if (!BOOLEAN_VALUES.has(normalized ?? "")) { + return mismatch(featureId, kind); + } + + return { value: TRUE_VALUES.has(normalized ?? "") }; + } + case "string": { + if (entitlement.value === undefined) { + return mismatch(featureId, kind); + } + + return { value: entitlement.value }; + } + case "number": { + const rawValue = entitlement.value?.trim(); + if (rawValue?.toLowerCase() === UNLIMITED) { + return { value: Number.POSITIVE_INFINITY }; + } + + const value = rawValue ? Number(rawValue) : Number.NaN; + if (!Number.isFinite(value)) { + return mismatch(featureId, kind); + } + + return { value }; + } + case "object": + return { value: entitlement }; + } +} + +/** + * Resolves an entitlement into whatever shape `defaultValue` declares, and + * falls back to that default when the feature is missing, disabled, expired, + * or holds a value of another shape. + */ +export function resolveEntitlement( + snapshot: ChargebeeEntitlementsSnapshot, + featureId: string, + defaultValue: T, + source: SnapshotSource, +): EntitlementDetails { + const entitlement = snapshot.entitlements[featureId]; + if (!entitlement) { + return { + ...errorDetails( + defaultValue, + "not-found", + `Chargebee feature ${featureId} was not found`, + ), + source, + }; + } + + if (!isActive(entitlement)) { + return { value: defaultValue, status: "disabled", source, entitlement }; + } + + const parsed = parseValue(entitlement, kindOf(defaultValue), featureId); + if ("error" in parsed) { + return { + value: defaultValue, + status: "error", + source, + entitlement, + error: parsed.error, + }; + } + + return { value: parsed.value as T, status: "granted", source, entitlement }; +} diff --git a/packages/entitlements/src/shared/feature.ts b/packages/entitlements/src/shared/feature.ts new file mode 100644 index 0000000..0e3a225 --- /dev/null +++ b/packages/entitlements/src/shared/feature.ts @@ -0,0 +1,120 @@ +import type { ChargebeeTarget, EntitlementDetails } from "./types"; + +/** + * A server client (`ChargebeeEntitlements` from `/server`): every evaluation + * names a target. + */ +export interface TargetedEntitlementsClient { + getDetails( + featureId: string, + defaultValue: T, + target: ChargebeeTarget, + ): Promise>; +} + +/** + * A client already bound to one target, so evaluations take none: the + * browser client (its relay snapshot belongs to the session), or a server + * client scoped with `entitlements.for(target)`. + */ +export interface ScopedEntitlementsClient { + getDetails( + featureId: string, + defaultValue: T, + ): EntitlementDetails | Promise>; +} + +/** Any client a {@link Feature} can evaluate against. */ +export type EntitlementsClient = + | TargetedEntitlementsClient + | ScopedEntitlementsClient; + +/** + * `get` arguments for a client type: + * + * - server client → `get(target)` + * - scoped client (browser, `entitlements.for(target)`) → `get()` + * - unknown (standalone feature, default client) → either + */ +type TargetArgs = + C extends ScopedEntitlementsClient ? [] : [target: ChargebeeTarget]; + +/** + * How a {@link Feature} calls whichever client it resolves to. A web client + * ignores the extra `target` argument. + */ +interface AnyEntitlementsClient { + getDetails( + featureId: string, + defaultValue: T, + target?: ChargebeeTarget, + ): Promise> | EntitlementDetails; +} + +let defaultClient: EntitlementsClient | undefined; + +/** + * Registers the client that standalone {@link Feature} instances evaluate + * against. Call it once during application start-up, before the first + * `feature.get(...)`. Pass `undefined` to clear it (useful in tests). + */ +export function setDefaultEntitlements( + client: EntitlementsClient | undefined, +): void { + defaultClient = client; +} + +/** + * A declared feature whose value is fetched with a single `get` call, typed as + * whatever the feature holds: + * + * ```ts + * const seats = new Feature("licensed-seats", 0); + * + * const count = await seats.get({ customerId: user.chargebeeCustomerId }); + * ``` + * + * The type parameter is optional — `new Feature("licensed-seats", 0)` infers + * `Feature` from the default value, which is also what tells the + * resolver at runtime to read Chargebee's stored `"25"` as a number. + * + * A standalone feature resolves against the client registered with + * {@link setDefaultEntitlements}. Pass a client as the third argument, or use + * `entitlements.feature(...)`, to bind one instead. A bound feature's `get` + * requires a target on a server client and takes none on a scoped one. + */ +export class Feature { + constructor( + readonly featureId: string, + readonly defaultValue: T, + private readonly client?: C, + ) { + if (!featureId) throw new Error("featureId is required"); + } + + /** + * Resolves the feature's value for `target`, falling back to the default + * value when the feature is missing, disabled, or the snapshot is + * unavailable. On the browser web client there is no target, because the + * relay snapshot is already scoped to the session. + */ + async get(...args: TargetArgs): Promise { + return (await this.getDetails(...args)).value; + } + + /** Like {@link get}, but returns the status and entitlement behind the value. */ + async getDetails(...args: TargetArgs): Promise> { + const [target] = args; + const client: AnyEntitlementsClient | undefined = + this.client ?? defaultClient; + if (!client) { + throw new Error( + `No Chargebee entitlements client is configured for feature "${this.featureId}". ` + + "Register one with setDefaultEntitlements(client), or create the " + + "feature with entitlements.feature(...).", + ); + } + + return client.getDetails(this.featureId, this.defaultValue, target); + } +} diff --git a/packages/entitlements/src/shared/index.ts b/packages/entitlements/src/shared/index.ts new file mode 100644 index 0000000..2faa645 --- /dev/null +++ b/packages/entitlements/src/shared/index.ts @@ -0,0 +1,27 @@ +export { errorDetails, resolveEntitlement } from "./evaluation"; +export { + type EntitlementsClient, + Feature, + type ScopedEntitlementsClient, + setDefaultEntitlements, + type TargetedEntitlementsClient, +} from "./feature"; +export { + createEntitlementsSnapshot, + isSnapshotExpired, + parseEntitlementsSnapshot, + parseSerializedEntitlementsSnapshot, + serializeEntitlementsSnapshot, +} from "./snapshot"; +export { assertTarget } from "./target"; +export type { + ChargebeeEntitlement, + ChargebeeEntitlementsSnapshot, + ChargebeeTarget, + EntitlementDetails, + EntitlementError, + EntitlementErrorCode, + EntitlementStatus, + Logger, + SnapshotSource, +} from "./types"; diff --git a/packages/entitlements/src/shared/snapshot.ts b/packages/entitlements/src/shared/snapshot.ts new file mode 100644 index 0000000..a6d1742 --- /dev/null +++ b/packages/entitlements/src/shared/snapshot.ts @@ -0,0 +1,97 @@ +import type { + ChargebeeEntitlement, + ChargebeeEntitlementsSnapshot, +} from "./types"; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isValidIsoDate(value: unknown): value is string { + return typeof value === "string" && !Number.isNaN(Date.parse(value)); +} + +function validateEntitlement( + key: string, + value: unknown, +): ChargebeeEntitlement { + if (!isObject(value)) { + throw new Error(`Invalid entitlement for feature "${key}"`); + } + const { featureId, isEnabled } = value; + if (typeof featureId !== "string" || featureId.length === 0) { + throw new Error(`Invalid or missing featureId in entitlement for "${key}"`); + } + if (typeof isEnabled !== "boolean") { + throw new Error(`Invalid or missing isEnabled in entitlement for "${key}"`); + } + return value as unknown as ChargebeeEntitlement; +} + +export function parseEntitlementsSnapshot( + input: unknown, +): ChargebeeEntitlementsSnapshot { + if (!isObject(input)) { + throw new Error("Invalid entitlements snapshot: expected an object"); + } + + const { schemaVersion, generatedAt, expiresAt, entitlements } = input; + + if (schemaVersion !== 1) { + throw new Error( + `Unsupported snapshot schemaVersion: ${String(schemaVersion)}`, + ); + } + if (!isValidIsoDate(generatedAt)) { + throw new Error("Invalid snapshot generatedAt timestamp"); + } + if (!isValidIsoDate(expiresAt)) { + throw new Error("Invalid snapshot expiresAt timestamp"); + } + if (!isObject(entitlements)) { + throw new Error("Invalid snapshot entitlements map"); + } + + for (const [key, ent] of Object.entries(entitlements)) { + validateEntitlement(key, ent); + } + + return input as unknown as ChargebeeEntitlementsSnapshot; +} + +export function serializeEntitlementsSnapshot( + snapshot: ChargebeeEntitlementsSnapshot, +): string { + return JSON.stringify(snapshot); +} + +export function parseSerializedEntitlementsSnapshot( + value: string, +): ChargebeeEntitlementsSnapshot { + return parseEntitlementsSnapshot(JSON.parse(value)); +} + +export function createEntitlementsSnapshot( + entitlements: Iterable, + ttlMs: number, + now = Date.now(), +): ChargebeeEntitlementsSnapshot { + return { + schemaVersion: 1, + generatedAt: new Date(now).toISOString(), + expiresAt: new Date(now + ttlMs).toISOString(), + entitlements: Object.fromEntries( + [...entitlements].map((entitlement) => [ + entitlement.featureId, + entitlement, + ]), + ), + }; +} + +export function isSnapshotExpired( + snapshot: ChargebeeEntitlementsSnapshot, + now = Date.now(), +): boolean { + return Date.parse(snapshot.expiresAt) <= now; +} diff --git a/packages/entitlements/src/shared/target.ts b/packages/entitlements/src/shared/target.ts new file mode 100644 index 0000000..640aab5 --- /dev/null +++ b/packages/entitlements/src/shared/target.ts @@ -0,0 +1,30 @@ +import type { ChargebeeTarget } from "./types"; + +const nonEmpty = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; + +/** + * Reduces a target to the single identifier it evaluates against, ignoring + * any other properties a caller's context object carries. + * + * Both identifiers at once is rejected rather than prioritized: a customer's + * consolidated entitlements and one subscription's entitlements are different + * answers, and guessing which one was meant hides the mistake. + */ +export function assertTarget( + target: ChargebeeTarget | undefined, +): ChargebeeTarget { + const customerId = nonEmpty(target?.customerId); + const subscriptionId = nonEmpty(target?.subscriptionId); + + if (customerId && subscriptionId) { + throw new Error( + "A Chargebee target takes customerId or subscriptionId, not both", + ); + } + if (customerId) return { customerId }; + if (subscriptionId) return { subscriptionId }; + throw new Error( + "A Chargebee target requires a non-empty customerId or subscriptionId", + ); +} diff --git a/packages/entitlements/src/shared/types.ts b/packages/entitlements/src/shared/types.ts new file mode 100644 index 0000000..0fe99e9 --- /dev/null +++ b/packages/entitlements/src/shared/types.ts @@ -0,0 +1,86 @@ +/** + * Who an entitlement is evaluated for: a Chargebee customer, whose + * entitlements are consolidated across their subscriptions, or a single + * subscription. Exactly one identifier, so there is nothing to configure and + * no mode to reconcile. + */ +export type ChargebeeTarget = + | { customerId: string; subscriptionId?: never } + | { subscriptionId: string; customerId?: never }; + +/** A minimal, framework-agnostic logger. Structurally compatible with `console`. */ +export interface Logger { + error(...args: unknown[]): void; + warn(...args: unknown[]): void; + info(...args: unknown[]): void; + debug(...args: unknown[]): void; +} + +export interface ChargebeeEntitlement { + featureId: string; + value?: string; + name?: string; + featureName?: string; + featureUnit?: string; + featureType?: string; + isEnabled: boolean; + isOverridden?: boolean; + expiresAt?: number; +} + +export interface ChargebeeEntitlementsSnapshot { + schemaVersion: 1; + generatedAt: string; + expiresAt: string; + entitlements: Record; +} + +/** + * Where a resolved snapshot came from: the Chargebee API, the shared cache, + * the durable snapshot store, or the browser relay. + */ +export type SnapshotSource = "api" | "cache" | "store" | "relay"; + +/** + * Why a feature resolved to its value: + * + * - `granted` — the entitlement is enabled and read as the declared type + * - `disabled` — the entitlement is disabled or expired; default returned + * - `pending` — no snapshot is loaded yet; default returned + * - `stale` — the browser snapshot expired and is refreshing; default returned + * - `error` — see {@link EntitlementDetails.error}; default returned + */ +export type EntitlementStatus = + | "granted" + | "disabled" + | "pending" + | "stale" + | "error"; + +/** + * - `not-found` — the snapshot has no entitlement for the feature + * - `type-mismatch` — the value cannot be read as the default value's type + * - `invalid-target` — the target names neither or both identifiers + * - `unavailable` — the snapshot could not be loaded + */ +export type EntitlementErrorCode = + | "not-found" + | "type-mismatch" + | "invalid-target" + | "unavailable"; + +export interface EntitlementError { + code: EntitlementErrorCode; + message: string; +} + +export interface EntitlementDetails { + value: T; + status: EntitlementStatus; + /** Where the snapshot came from, when one was read. */ + source?: SnapshotSource; + /** The Chargebee entitlement behind the value, when the feature exists. */ + entitlement?: ChargebeeEntitlement; + /** Set when `status` is `error`. */ + error?: EntitlementError; +} diff --git a/packages/entitlements/src/web/client.ts b/packages/entitlements/src/web/client.ts new file mode 100644 index 0000000..09cc632 --- /dev/null +++ b/packages/entitlements/src/web/client.ts @@ -0,0 +1,237 @@ +import { + type ChargebeeEntitlementsSnapshot, + type EntitlementDetails, + Feature, + isSnapshotExpired, + parseEntitlementsSnapshot, + resolveEntitlement, + type ScopedEntitlementsClient, +} from "../shared"; + +export interface SnapshotErrorInfo { + operation: "refresh"; +} + +export interface SnapshotRefreshedEvent { + snapshot: ChargebeeEntitlementsSnapshot; + /** Features whose entitlement differs from the previous snapshot. */ + changedFeatureIds: string[]; +} + +export interface ChargebeeEntitlementsOptions { + relayUrl: string | URL; + fetchImplementation?: typeof fetch; + credentials?: RequestCredentials; + requestHeaders?: HeadersInit; + /** The current snapshot has expired and a refresh has started. */ + onSnapshotExpired?: () => void; + /** A refresh (not the initial load) completed. */ + onSnapshotRefreshed?: (event: SnapshotRefreshedEvent) => void; + onError?: (error: unknown, info: SnapshotErrorInfo) => void; +} + +/** + * `initial` loads come from `initialize` and report nothing; `refresh` loads + * report through `onSnapshotRefreshed` and `onError`. + */ +type LoadKind = "initial" | "refresh"; + +const changedFeatures = ( + before: ChargebeeEntitlementsSnapshot | undefined, + after: ChargebeeEntitlementsSnapshot, +) => + [ + ...new Set([ + ...Object.keys(before?.entitlements ?? {}), + ...Object.keys(after.entitlements), + ]), + ].filter( + (featureId) => + JSON.stringify(before?.entitlements[featureId]) !== + JSON.stringify(after.entitlements[featureId]), + ); + +/** + * Framework-agnostic Chargebee entitlements client for browsers. Fetches a + * sanitized snapshot from an authenticated relay endpoint (see + * `@chargebee/entitlements/server` or `@chargebee/entitlements/nextjs`) and + * evaluates feature IDs against it synchronously. + */ +export class ChargebeeEntitlements { + private readonly relayUrl: string; + private readonly fetchImplementation: typeof fetch; + private readonly credentials: RequestCredentials; + private readonly requestHeaders?: HeadersInit; + private readonly onSnapshotExpired?: () => void; + private readonly onSnapshotRefreshed?: ( + event: SnapshotRefreshedEvent, + ) => void; + private readonly onError?: (error: unknown, info: SnapshotErrorInfo) => void; + private snapshot?: ChargebeeEntitlementsSnapshot; + private expiryReported = false; + private closed = false; + private refreshInProgress = false; + private loads = new AbortController(); + + constructor(options: ChargebeeEntitlementsOptions) { + if (!options?.relayUrl) throw new Error("relayUrl is required"); + this.relayUrl = options.relayUrl.toString(); + this.fetchImplementation = options.fetchImplementation ?? globalThis.fetch; + if (!this.fetchImplementation) { + throw new Error("A fetch implementation is required"); + } + this.credentials = options.credentials ?? "same-origin"; + this.requestHeaders = options.requestHeaders; + this.onSnapshotExpired = options.onSnapshotExpired; + this.onSnapshotRefreshed = options.onSnapshotRefreshed; + this.onError = options.onError; + } + + async initialize(): Promise { + this.closed = false; + await this.loadSnapshot("initial"); + } + + /** Clears the current snapshot and reloads it, e.g. after the session's billing subject changes. */ + async reset(): Promise { + this.supersedeLoads(); + this.snapshot = undefined; + this.expiryReported = false; + this.refreshInProgress = false; + await this.refreshSnapshot(); + } + + async close(): Promise { + this.supersedeLoads(); + this.closed = true; + this.snapshot = undefined; + this.expiryReported = false; + this.refreshInProgress = false; + } + + refreshSnapshot(): Promise { + return this.loadSnapshot("refresh"); + } + + /** + * Resolves a feature into whatever shape `defaultValue` declares, against + * the snapshot currently held in memory. The relay scopes that snapshot to + * the authenticated session, so there is no target to pass. + */ + get(featureId: string, defaultValue: T): T { + return this.getDetails(featureId, defaultValue).value; + } + + /** Like {@link get}, but returns the status and entitlement behind the value. */ + getDetails(featureId: string, defaultValue: T): EntitlementDetails { + if (!this.snapshot) { + return { value: defaultValue, status: "pending" }; + } + + if (isSnapshotExpired(this.snapshot)) { + this.refreshExpired(); + return { value: defaultValue, status: "stale" }; + } + + return resolveEntitlement(this.snapshot, featureId, defaultValue, "relay"); + } + + /** + * Declares a feature bound to this client, evaluated synchronously against + * the current relay snapshot. + */ + feature( + featureId: string, + defaultValue: T, + ): Feature { + return new Feature( + featureId, + defaultValue, + this, + ); + } + + /** Reports the expiry once and starts a single background refresh. */ + private refreshExpired(): void { + if (!this.expiryReported) { + this.onSnapshotExpired?.(); + this.expiryReported = true; + } + + if (this.refreshInProgress || this.closed) { + return; + } + + this.refreshInProgress = true; + this.refreshSnapshot() + .catch(() => { + // Reported through onError inside loadSnapshot. + }) + .finally(() => { + this.refreshInProgress = false; + }); + } + + /** + * Aborts loads started before a `reset` or `close`, so a late response for + * the previous session cannot overwrite (or resurrect) the snapshot. + */ + private supersedeLoads(): void { + this.loads.abort(); + this.loads = new AbortController(); + } + + private async loadSnapshot(kind: LoadKind): Promise { + if (this.closed) throw new Error("Chargebee web client is closed"); + + const { signal } = this.loads; + try { + const headers = new Headers(this.requestHeaders); + if (!headers.has("Accept")) headers.set("Accept", "application/json"); + + // Called unbound: browsers' fetch throws when `this` is not the window. + const fetchImplementation = this.fetchImplementation; + const response = await fetchImplementation(this.relayUrl, { + method: "GET", + cache: "no-store", + credentials: this.credentials, + headers, + signal, + }); + if (!response.ok) { + throw new Error( + `Chargebee entitlement relay returned HTTP ${response.status}`, + ); + } + + const nextSnapshot = parseEntitlementsSnapshot(await response.json()); + if (signal.aborted) { + return; + } + + const changedFeatureIds = changedFeatures(this.snapshot, nextSnapshot); + this.snapshot = nextSnapshot; + + // An already-expired snapshot is still stale; report it once. + if (!isSnapshotExpired(nextSnapshot)) { + this.expiryReported = false; + } + + if (kind === "refresh") { + this.onSnapshotRefreshed?.({ + snapshot: nextSnapshot, + changedFeatureIds, + }); + } + } catch (error) { + if (signal.aborted) { + return; + } + + if (kind === "refresh") { + this.onError?.(error, { operation: "refresh" }); + } + throw error; + } + } +} diff --git a/packages/entitlements/src/web/index.ts b/packages/entitlements/src/web/index.ts new file mode 100644 index 0000000..7515ff5 --- /dev/null +++ b/packages/entitlements/src/web/index.ts @@ -0,0 +1,6 @@ +export { + ChargebeeEntitlements, + type ChargebeeEntitlementsOptions, + type SnapshotErrorInfo, + type SnapshotRefreshedEvent, +} from "./client"; diff --git a/packages/entitlements/test/cache.test.ts b/packages/entitlements/test/cache.test.ts new file mode 100644 index 0000000..4a91c2f --- /dev/null +++ b/packages/entitlements/test/cache.test.ts @@ -0,0 +1,109 @@ +import { + createMemoryEntitlementsCache, + createRedisEntitlementsCache, + type RedisEntitlementsCacheClient, +} from "../src/cache"; +import { createEntitlementsSnapshot } from "../src/shared"; + +/** A minimal stand-in for the ioredis methods `createRedisEntitlementsCache` uses. */ +function makeRedisClient(values = new Map()) { + const mocks = { + get: vi.fn(async (key: string) => values.get(key) ?? null), + set: vi.fn(async (key: string, value: string) => { + values.set(key, value); + return "OK" as const; + }), + del: vi.fn(async (key: string) => (values.delete(key) ? 1 : 0)), + }; + return { client: mocks as unknown as RedisEntitlementsCacheClient, mocks }; +} + +function makeSnapshot(ttlMs = 60_000) { + return createEntitlementsSnapshot( + [{ featureId: "sso", value: "true", isEnabled: true }], + ttlMs, + ); +} + +describe("memory entitlement cache", () => { + it("expires entries and evicts the least recently used entry", async () => { + let now = 1_000; + const cache = createMemoryEntitlementsCache({ + maxEntries: 2, + now: () => now, + }); + const snapshot = makeSnapshot(); + + await cache.set("a", snapshot, 100); + await cache.set("b", snapshot, 100); + await cache.get("a"); + await cache.set("c", snapshot, 100); + + expect(await cache.get("a")).toBe(snapshot); + expect(await cache.get("b")).toBeUndefined(); + now += 101; + expect(await cache.get("a")).toBeUndefined(); + }); + + it("applies the configured TTL when the caller does not pass one", async () => { + let now = 1_000; + const cache = createMemoryEntitlementsCache({ ttlMs: 500, now: () => now }); + + await cache.set("a", makeSnapshot()); + + now += 499; + expect(await cache.get("a")).toBeDefined(); + now += 2; + expect(await cache.get("a")).toBeUndefined(); + }); +}); + +describe("Redis entitlement cache", () => { + it("serializes snapshots and removes corrupt values", async () => { + const values = new Map(); + const { client, mocks } = makeRedisClient(values); + const cache = createRedisEntitlementsCache(client); + const snapshot = makeSnapshot(); + + await cache.set("valid", snapshot, 300_000); + expect(await cache.get("valid")).toEqual(snapshot); + + values.set("invalid", "{not-json"); + expect(await cache.get("invalid")).toBeUndefined(); + expect(mocks.del).toHaveBeenCalledWith("invalid"); + }); + + it("applies the configured TTL when the caller does not pass one", async () => { + const { client, mocks } = makeRedisClient(); + const cache = createRedisEntitlementsCache(client, { ttlMs: 45_000 }); + const snapshot = makeSnapshot(); + + await cache.set("default-ttl", snapshot); + await cache.set("explicit-ttl", snapshot, 1_000); + + expect(mocks.set).toHaveBeenNthCalledWith( + 1, + "default-ttl", + expect.any(String), + "PX", + 45_000, + ); + expect(mocks.set).toHaveBeenNthCalledWith( + 2, + "explicit-ttl", + expect.any(String), + "PX", + 1_000, + ); + }); + + it("leaves snapshot freshness to the provider", async () => { + const { client } = makeRedisClient(); + const cache = createRedisEntitlementsCache(client); + const expired = makeSnapshot(-1_000); + + await cache.set("expired", expired, 1_000); + + expect(await cache.get("expired")).toEqual(expired); + }); +}); diff --git a/packages/entitlements/test/feature.test.ts b/packages/entitlements/test/feature.test.ts new file mode 100644 index 0000000..5e7cb88 --- /dev/null +++ b/packages/entitlements/test/feature.test.ts @@ -0,0 +1,216 @@ +import type { CustomerEntitlement } from "chargebee"; +import { ChargebeeEntitlements } from "../src/server"; +import type { ChargebeeEntitlementsClient } from "../src/server/loader"; +import type { + ChargebeeTarget, + EntitlementDetails, + EntitlementsClient, +} from "../src/shared"; +import { Feature, setDefaultEntitlements } from "../src/shared"; + +function makeEntitlements() { + const customerRequest = vi.fn(async () => ({ + list: [ + { + customer_entitlement: { + customer_id: "customer-1", + feature_id: "licensed-seats", + value: "25", + is_enabled: true, + }, + }, + { + customer_entitlement: { + customer_id: "customer-1", + feature_id: "advanced-reports", + value: "true", + is_enabled: true, + }, + }, + { + customer_entitlement: { + customer_id: "customer-1", + feature_id: "support-tier", + value: "priority", + is_enabled: true, + }, + }, + ] as Array<{ customer_entitlement: CustomerEntitlement }>, + })); + const client = { + customerEntitlement: { entitlementsForCustomer: customerRequest }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: vi.fn(), + }, + } as unknown as ChargebeeEntitlementsClient; + return new ChargebeeEntitlements({ chargebeeClient: client }); +} + +const target: ChargebeeTarget = { customerId: "customer-1" }; + +afterEach(() => { + setDefaultEntitlements(undefined); +}); + +describe("Feature", () => { + it("resolves the unwrapped value against the default client", async () => { + setDefaultEntitlements(makeEntitlements()); + + const licensedSeats = new Feature("licensed-seats", 0); + + const seats = await licensedSeats.get(target); + + expectTypeOf(seats).toEqualTypeOf(); + expect(seats).toBe(25); + }); + + it("takes the value type from the declared default value", async () => { + const entitlements = makeEntitlements(); + + const advancedReports = entitlements.feature("advanced-reports", false); + const supportTier = entitlements.feature("support-tier", "basic"); + const seats = entitlements.feature("licensed-seats", 0); + + const enabled = await advancedReports.get(target); + const tier = await supportTier.get(target); + const count = await seats.get(target); + + expectTypeOf(enabled).toEqualTypeOf(); + expectTypeOf(tier).toEqualTypeOf(); + expectTypeOf(count).toEqualTypeOf(); + expect({ enabled, tier, count }).toEqual({ + enabled: true, + tier: "priority", + count: 25, + }); + }); + + it("infers the type parameter from the default value", async () => { + setDefaultEntitlements(makeEntitlements()); + + const seats = new Feature("licensed-seats", 0); + + expectTypeOf(seats).toEqualTypeOf>(); + expectTypeOf(await seats.get(target)).toEqualTypeOf(); + }); + + it("resolves object features to the declared shape", async () => { + const entitlements = makeEntitlements(); + const supportTier = entitlements.feature( + "support-tier", + {} as { featureId: string; value?: string }, + ); + + const details = await supportTier.getDetails(target); + + expectTypeOf(details).toEqualTypeOf< + EntitlementDetails<{ featureId: string; value?: string }> + >(); + expect(details.value).toMatchObject({ + featureId: "support-tier", + value: "priority", + }); + }); + + it("exposes the status and entitlement via getDetails", async () => { + const seats = makeEntitlements().feature("licensed-seats", 0); + + const details = await seats.getDetails(target); + + expect(details).toMatchObject({ + value: 25, + status: "granted", + source: "api", + entitlement: { featureId: "licensed-seats", value: "25" }, + }); + }); + + it("falls back to the default value when the feature is missing", async () => { + setDefaultEntitlements(makeEntitlements()); + const missing = new Feature("does-not-exist", 7); + + await expect(missing.get(target)).resolves.toBe(7); + await expect(missing.getDetails(target)).resolves.toMatchObject({ + value: 7, + status: "error", + error: { code: "not-found" }, + }); + }); + + it("prefers a bound client over the default one", async () => { + const calls: string[] = []; + const stub = (name: string, value: number): EntitlementsClient => ({ + getDetails: async () => { + calls.push(name); + return { value: value as never, status: "granted" as const }; + }, + }); + setDefaultEntitlements(stub("default", 1)); + + const seats = new Feature("licensed-seats", 0, stub("bound", 2)); + + await expect(seats.get(target)).resolves.toBe(2); + expect(calls).toEqual(["bound"]); + }); + + it("binds a client through the constructor", async () => { + const seats = new Feature("licensed-seats", 0, makeEntitlements()); + + await expect(seats.get(target)).resolves.toBe(25); + }); + + it("rejects a missing target on the server as invalid-target", async () => { + setDefaultEntitlements(makeEntitlements()); + const seats = new Feature("licensed-seats", 0); + + await expect(seats.getDetails()).resolves.toMatchObject({ + value: 0, + status: "error", + error: { + code: "invalid-target", + message: expect.stringMatching(/customerId or subscriptionId/), + }, + }); + }); + + it("requires a target on features bound to a server client", async () => { + const seats = makeEntitlements().feature("licensed-seats", 0); + + // @ts-expect-error A server feature needs a target. + await expect(seats.get()).resolves.toBe(0); + await expect(seats.get(target)).resolves.toBe(25); + }); + + it("throws a helpful error when no client is configured", async () => { + const seats = new Feature("licensed-seats", 0); + + await expect(seats.get(target)).rejects.toThrow( + /No Chargebee entitlements client is configured/, + ); + }); +}); + +describe("ChargebeeEntitlements.get", () => { + it("resolves every shape through a single method", async () => { + const entitlements = makeEntitlements(); + + await expect( + entitlements.get("licensed-seats", 0, target), + ).resolves.toBe(25); + await expect( + entitlements.get("support-tier", "basic", target), + ).resolves.toBe("priority"); + await expect( + entitlements.get("advanced-reports", false, target), + ).resolves.toBe(true); + await expect( + entitlements.get("support-tier", {}, target), + ).resolves.toMatchObject({ featureId: "support-tier" }); + }); + + it("returns the status and entitlement via getDetails", async () => { + await expect( + makeEntitlements().getDetails("licensed-seats", 0, target), + ).resolves.toMatchObject({ value: 25, status: "granted" }); + }); +}); diff --git a/packages/entitlements/test/server.test.ts b/packages/entitlements/test/server.test.ts new file mode 100644 index 0000000..3a551c2 --- /dev/null +++ b/packages/entitlements/test/server.test.ts @@ -0,0 +1,729 @@ +import type { CustomerEntitlement } from "chargebee"; +import { + createMemoryEntitlementsCache, + type EntitlementsStorage, +} from "../src/cache"; +import { + ChargebeeEntitlements, + createEntitlementsRelayHandler, + createEntitlementsSnapshot, +} from "../src/server"; +import type { ChargebeeEntitlementsClient } from "../src/server/loader"; +import type { + ChargebeeEntitlementsSnapshot, + ChargebeeTarget, +} from "../src/shared"; + +function makeClient( + customerPages: Array<{ + list: Array<{ customer_entitlement: CustomerEntitlement }>; + next_offset?: string; + }>, +) { + const customerRequest = vi.fn(async (_id: string, input?: { offset?: string }) => { + const index = input?.offset ? Number(input.offset) : 0; + return customerPages[index] ?? { list: [] }; + }); + const subscriptionRequest = vi.fn(async () => ({ list: [] })); + const client = { + customerEntitlement: { + entitlementsForCustomer: customerRequest, + }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: subscriptionRequest, + }, + } as unknown as ChargebeeEntitlementsClient; + return { client, customerRequest, subscriptionRequest }; +} + +const target: ChargebeeTarget = { customerId: "customer-1" }; + +/** A durable store keeps snapshots past `expiresAt`; the client refreshes them. */ +function makeDurableStore(): EntitlementsStorage { + const snapshots = new Map(); + return { + get: async (key) => snapshots.get(key), + set: async (key, snapshot) => { + snapshots.set(key, snapshot); + }, + delete: async (key) => { + snapshots.delete(key); + }, + }; +} + +const ssoPage = { + list: [ + { + customer_entitlement: { + customer_id: "customer-1", + feature_id: "sso", + value: "true", + is_enabled: true, + }, + }, + ], +}; + +describe("ChargebeeEntitlements", () => { + it("loads all customer entitlements once and reuses the target snapshot", async () => { + const { client, customerRequest } = makeClient([ + { + list: [ + { + customer_entitlement: { + customer_id: "customer-1", + feature_id: "sso", + value: "true", + is_enabled: true, + }, + }, + ], + next_offset: "1", + }, + { + list: [ + { + customer_entitlement: { + customer_id: "customer-1", + feature_id: "seats", + value: "10", + is_enabled: true, + }, + }, + ], + }, + ]); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: client, + cache: createMemoryEntitlementsCache(), + cacheTtlMs: 60_000, + }); + + const first = await entitlements.getDetails("sso", false, target); + const second = await entitlements.getDetails("seats", 0, target); + + expect(first).toMatchObject({ value: true, source: "api" }); + expect(second).toMatchObject({ value: 10, source: "cache" }); + expect(customerRequest).toHaveBeenCalledTimes(2); + expect(customerRequest).toHaveBeenLastCalledWith( + "customer-1", + expect.objectContaining({ offset: "1", limit: 100 }), + ); + }); + + it("deduplicates concurrent cache misses", async () => { + let release: (() => void) | undefined; + const pending = new Promise((resolve) => { + release = resolve; + }); + const customerRequest = vi.fn(async () => { + await pending; + return { + list: [ + { + customer_entitlement: { + customer_id: "customer-1", + feature_id: "sso", + value: "true", + is_enabled: true, + }, + }, + ], + }; + }); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: { + customerEntitlement: { + entitlementsForCustomer: customerRequest, + }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: vi.fn(), + }, + } as unknown as ChargebeeEntitlementsClient, + }); + + const first = entitlements.getSnapshot(target); + const second = entitlements.getSnapshot(target); + release?.(); + + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + expect(customerRequest).toHaveBeenCalledTimes(1); + }); + + it("does not repopulate the cache with a request invalidated in flight", async () => { + let release: (() => void) | undefined; + let markStarted: (() => void) | undefined; + const pending = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const customerRequest = vi.fn(async () => { + markStarted?.(); + await pending; + return { list: [] }; + }); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: { + customerEntitlement: { + entitlementsForCustomer: customerRequest, + }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: vi.fn(), + }, + } as unknown as ChargebeeEntitlementsClient, + }); + + const first = entitlements.getSnapshot(target); + await started; + await entitlements.deleteSnapshot(target); + release?.(); + await first; + await entitlements.getSnapshot(target); + + expect(customerRequest).toHaveBeenCalledTimes(2); + }); + + it("returns invalid-target without calling Chargebee", async () => { + const { client, customerRequest } = makeClient([]); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: client, + }); + + await expect( + entitlements.getDetails("sso", false, { userId: "app-user-1" } as never), + ).resolves.toMatchObject({ + value: false, + error: { code: "invalid-target" }, + }); + await expect( + entitlements.getDetails("sso", false, { + customerId: "customer-1", + subscriptionId: "subscription-1", + } as never), + ).resolves.toMatchObject({ + value: false, + error: { code: "invalid-target" }, + }); + expect(customerRequest).not.toHaveBeenCalled(); + }); + + it("loads subscription-scoped entitlements", async () => { + const subscriptionRequest = vi.fn(async () => ({ + list: [ + { + subscription_entitlement: { + subscription_id: "subscription-1", + feature_id: "seats", + feature_name: "Licensed seats", + feature_unit: "seat", + feature_type: "quantity", + value: "50", + is_overridden: true, + is_enabled: true, + }, + }, + ], + })); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: { + customerEntitlement: { + entitlementsForCustomer: vi.fn(), + }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: subscriptionRequest, + }, + } as unknown as ChargebeeEntitlementsClient, + }); + + await expect( + entitlements.getDetails("seats", 0, { subscriptionId: "subscription-1" }), + ).resolves.toMatchObject({ + value: 50, + entitlement: { featureType: "quantity", isOverridden: true }, + }); + expect(subscriptionRequest).toHaveBeenCalledWith( + "subscription-1", + expect.objectContaining({ limit: 100 }), + ); + }); + + it("ignores unrelated properties on a context-shaped target", async () => { + const { client } = makeClient([ssoPage]); + const entitlements = new ChargebeeEntitlements({ chargebeeClient: client }); + const context = { + userId: "app-user-1", + customerId: "customer-1", + }; + + await expect(entitlements.get("sso", false, context)).resolves.toBe(true); + }); + + it("reads the cache first, then the store, and hydrates the cache", async () => { + const { client, customerRequest } = makeClient([ssoPage]); + const cache = createMemoryEntitlementsCache(); + const store = createMemoryEntitlementsCache(); + await new ChargebeeEntitlements({ + chargebeeClient: client, + durableStore: store, + }).refreshSnapshot(target); + + const reader = new ChargebeeEntitlements({ + chargebeeClient: client, + cache, + durableStore: store, + refreshOnMiss: "background", + }); + + await expect(reader.getSnapshot(target)).resolves.toMatchObject({ + source: "store", + }); + await expect(reader.getSnapshot(target)).resolves.toMatchObject({ + source: "cache", + }); + expect(customerRequest).toHaveBeenCalledTimes(1); + }); + + it("falls back to the store when the cache is unavailable", async () => { + const { client } = makeClient([ssoPage]); + const store = createMemoryEntitlementsCache(); + const onError = vi.fn(); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: client, + cache: { + get: vi.fn(async () => { + throw new Error("redis unavailable"); + }), + set: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + }, + durableStore: store, + onError, + }); + await entitlements.refreshSnapshot(target); + + await expect( + entitlements.getDetails("sso", false, target), + ).resolves.toMatchObject({ + value: true, + status: "granted", + source: "store", + }); + expect(onError).toHaveBeenCalledWith(expect.any(Error), { + operation: "cache-read", + target: { customerId: "customer-1" }, + }); + }); + + it("evicts the cached snapshot before priming a new one", async () => { + const { client } = makeClient([ssoPage, ssoPage]); + const store = makeDurableStore(); + const cache = createMemoryEntitlementsCache(); + const evict = vi.spyOn(cache, "delete"); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: client, + cache, + durableStore: store, + }); + + await entitlements.refreshSnapshot(target); + await entitlements.getSnapshot(target); + await entitlements.refreshSnapshot(target); + + expect(evict).toHaveBeenCalledTimes(2); + expect(evict).toHaveBeenCalledWith( + "chargebee:entitlements:v1:customer:customer-1:consolidated", + ); + }); + + it("does not join a request refresh that was already in flight", async () => { + let releaseFirst: (() => void) | undefined; + let finishFirst: (() => void) | undefined; + const firstPending = new Promise((resolve) => { + releaseFirst = resolve; + }); + const firstFinished = new Promise((resolve) => { + finishFirst = resolve; + }); + let requestCount = 0; + const customerRequest = vi.fn(async () => { + const requestNumber = ++requestCount; + if (requestNumber === 1) { + await firstPending; + finishFirst?.(); + return ssoPage; + } + return { + list: [ + { + customer_entitlement: { + customer_id: "customer-1", + feature_id: "sso", + value: "false", + is_enabled: true, + }, + }, + ], + }; + }); + const onSnapshotRefreshed = vi.fn(); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: { + customerEntitlement: { entitlementsForCustomer: customerRequest }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: vi.fn(), + }, + } as unknown as ChargebeeEntitlementsClient, + cache: createMemoryEntitlementsCache(), + durableStore: makeDurableStore(), + refreshOnMiss: "background", + onSnapshotRefreshed, + }); + + await expect( + entitlements.getDetails("sso", false, target), + ).resolves.toMatchObject({ status: "pending" }); + await vi.waitFor(() => expect(customerRequest).toHaveBeenCalledTimes(1)); + + const explicitRefresh = entitlements.refreshSnapshot(target); + await vi.waitFor(() => expect(customerRequest).toHaveBeenCalledTimes(2)); + await expect(explicitRefresh).resolves.toMatchObject({ + snapshot: { + entitlements: { sso: expect.objectContaining({ value: "false" }) }, + }, + }); + + releaseFirst?.(); + await firstFinished; + expect(onSnapshotRefreshed).toHaveBeenCalledTimes(1); + expect(onSnapshotRefreshed).toHaveBeenCalledWith( + expect.objectContaining({ trigger: "explicit" }), + ); + await expect(entitlements.getSnapshot(target)).resolves.toMatchObject({ + snapshot: { + entitlements: { sso: expect.objectContaining({ value: "false" }) }, + }, + }); + }); + + it("reports a pending snapshot and refreshes in the background", async () => { + const { client, customerRequest } = makeClient([ssoPage]); + const onSnapshotRefreshed = vi.fn(); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: client, + durableStore: createMemoryEntitlementsCache(), + refreshOnMiss: "background", + onSnapshotRefreshed, + }); + + await expect( + entitlements.getDetails("sso", false, target), + ).resolves.toEqual({ value: false, status: "pending" }); + + await vi.waitFor(() => + expect(onSnapshotRefreshed).toHaveBeenCalledWith( + expect.objectContaining({ trigger: "request" }), + ), + ); + await expect( + entitlements.getDetails("sso", false, target), + ).resolves.toMatchObject({ value: true, source: "store" }); + expect(customerRequest).toHaveBeenCalledTimes(1); + }); + + it("serves an expired stored snapshot while refreshing it", async () => { + const { client, customerRequest } = makeClient([ssoPage]); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: client, + durableStore: makeDurableStore(), + snapshotTtlMs: 1, + refreshOnMiss: "background", + }); + await entitlements.refreshSnapshot(target); + await new Promise((resolve) => setTimeout(resolve, 5)); + + await expect(entitlements.getSnapshot(target)).resolves.toMatchObject({ + source: "store", + }); + await vi.waitFor(() => expect(customerRequest).toHaveBeenCalledTimes(2)); + }); + + it("backs off after a failed background refresh", async () => { + const customerRequest = vi.fn(async () => { + throw new Error("chargebee unavailable"); + }); + const onError = vi.fn(); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: { + customerEntitlement: { entitlementsForCustomer: customerRequest }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: vi.fn(), + }, + } as unknown as ChargebeeEntitlementsClient, + refreshOnMiss: "background", + onError, + advanced: { refreshBackoffMs: 60_000 }, + }); + + await entitlements.get("sso", false, target); + await vi.waitFor(() => + expect(onError).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ operation: "refresh" }), + ), + ); + await entitlements.get("sso", false, target); + + expect(customerRequest).toHaveBeenCalledTimes(1); + }); + + it("reads from the API, writes the store, and evicts the cache", async () => { + const { client, customerRequest } = makeClient([ssoPage]); + const cache = createMemoryEntitlementsCache(); + const store = makeDurableStore(); + const cacheRead = vi.spyOn(cache, "get"); + const cacheWrite = vi.spyOn(cache, "set"); + const evict = vi.spyOn(cache, "delete"); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: client, + cache, + durableStore: store, + }); + await entitlements.writeSnapshot( + target, + createEntitlementsSnapshot([], 60_000), + ); + cacheWrite.mockClear(); + + await expect(entitlements.getFromAPI("sso", false, target)).resolves.toBe( + true, + ); + expect(customerRequest).toHaveBeenCalledTimes(1); + expect(cacheRead).not.toHaveBeenCalled(); + expect(cacheWrite).not.toHaveBeenCalled(); + expect(evict).toHaveBeenCalledWith( + "chargebee:entitlements:v1:customer:customer-1:consolidated", + ); + + await expect( + entitlements.getDetails("sso", false, target), + ).resolves.toMatchObject({ value: true, source: "store" }); + }); + + it("keeps an older in-flight refresh from overwriting an API read", async () => { + let release: (() => void) | undefined; + const pending = new Promise((resolve) => { + release = resolve; + }); + let requestCount = 0; + const customerRequest = vi.fn(async () => { + requestCount += 1; + if (requestCount === 1) { + await pending; + return { list: [] }; + } + return ssoPage; + }); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: { + customerEntitlement: { entitlementsForCustomer: customerRequest }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: vi.fn(), + }, + } as unknown as ChargebeeEntitlementsClient, + durableStore: makeDurableStore(), + refreshOnMiss: "background", + }); + + await entitlements.get("sso", false, target); + await vi.waitFor(() => expect(customerRequest).toHaveBeenCalledTimes(1)); + + await expect(entitlements.getFromAPI("sso", false, target)).resolves.toBe( + true, + ); + + release?.(); + await new Promise((resolve) => setTimeout(resolve, 5)); + await expect(entitlements.get("sso", false, target)).resolves.toBe(true); + }); + + it("returns the default when the API read fails", async () => { + const customerRequest = vi + .fn() + .mockResolvedValueOnce(ssoPage) + .mockRejectedValueOnce(new Error("chargebee unavailable")); + const onError = vi.fn(); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: { + customerEntitlement: { entitlementsForCustomer: customerRequest }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: vi.fn(), + }, + } as unknown as ChargebeeEntitlementsClient, + cache: createMemoryEntitlementsCache(), + onError, + }); + await entitlements.refreshSnapshot(target); + + await expect(entitlements.getFromAPI("sso", false, target)).resolves.toBe( + false, + ); + expect(onError).toHaveBeenCalledWith(expect.any(Error), { + operation: "refresh", + target, + }); + }); + + it("warns through the configured logger when Chargebee omits a feature_id", async () => { + const logger = { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }; + const { client } = makeClient([ + { list: [{ customer_entitlement: {} as CustomerEntitlement }] }, + ]); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: client, + logger, + }); + + await entitlements.getSnapshot(target); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("omitted a feature_id"), + ); + }); +}); + +describe("ChargebeeEntitlements.for", () => { + it("evaluates against the bound target without passing it per call", async () => { + const { client } = makeClient([ssoPage]); + const scoped = new ChargebeeEntitlements({ chargebeeClient: client }).for( + target, + ); + + await expect(scoped.get("sso", false)).resolves.toBe(true); + await expect(scoped.getDetails("sso", false)).resolves.toMatchObject({ + value: true, + status: "granted", + }); + }); + + it("shares the parent's snapshot", async () => { + const { client, customerRequest } = makeClient([ssoPage]); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: client, + cache: createMemoryEntitlementsCache(), + }); + + await entitlements.get("sso", false, target); + await entitlements.for(target).get("sso", false); + + expect(customerRequest).toHaveBeenCalledTimes(1); + }); + + it("binds features that take no target", async () => { + const { client } = makeClient([ssoPage]); + const sso = new ChargebeeEntitlements({ chargebeeClient: client }) + .for(target) + .feature("sso", false); + + await expect(sso.get()).resolves.toBe(true); + // @ts-expect-error A scoped feature takes no target. + await expect(sso.get(target)).resolves.toBe(true); + }); + + it("reports an invalid target on evaluation", async () => { + const { client, customerRequest } = makeClient([ssoPage]); + const scoped = new ChargebeeEntitlements({ chargebeeClient: client }).for( + {} as never, + ); + + await expect(scoped.getDetails("sso", false)).resolves.toMatchObject({ + value: false, + error: { code: "invalid-target" }, + }); + expect(customerRequest).not.toHaveBeenCalled(); + }); +}); + +describe("entitlement relay", () => { + it("derives identity on the server and rejects spoofed IDs", async () => { + const { client } = makeClient([{ list: [] }]); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: client, + }); + const resolveContext = vi.fn(async () => target); + const handler = createEntitlementsRelayHandler({ + entitlements, + resolveContext, + }); + + const spoofed = await handler( + new Request("https://example.com/api/entitlements?customerId=other"), + ); + expect(spoofed.status).toBe(400); + expect(resolveContext).not.toHaveBeenCalled(); + + const response = await handler( + new Request("https://example.com/api/entitlements", { + headers: { cookie: "session=valid" }, + }), + ); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toContain("no-store"); + const body = await response.json(); + expect(body).not.toHaveProperty("customerId"); + expect(JSON.stringify(body)).not.toContain("customer-1"); + }); + + it("returns 401 when the application session has no identity", async () => { + const { client } = makeClient([]); + const handler = createEntitlementsRelayHandler({ + entitlements: new ChargebeeEntitlements({ + chargebeeClient: client, + }), + resolveContext: async () => null, + }); + + const response = await handler( + new Request("https://example.com/api/entitlements"), + ); + expect(response.status).toBe(401); + }); + + it("lets the browser trust a stale snapshot for the relay TTL", async () => { + const { client } = makeClient([ssoPage]); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: client, + durableStore: makeDurableStore(), + }); + const expired = createEntitlementsSnapshot( + [{ featureId: "sso", value: "true", isEnabled: true }], + 500, + Date.now() - 1_000, + ); + await entitlements.writeSnapshot(target, expired); + + const relayed = await entitlements.getRelaySnapshot(target, 60_000); + + expect(Date.parse(relayed.expiresAt)).toBeGreaterThan(Date.now()); + }); + + it("rejects methods other than GET", async () => { + const { client } = makeClient([]); + const resolveContext = vi.fn(async () => target); + const handler = createEntitlementsRelayHandler({ + entitlements: new ChargebeeEntitlements({ + chargebeeClient: client, + }), + resolveContext, + }); + + const response = await handler( + new Request("https://example.com/api/entitlements", { method: "POST" }), + ); + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("GET"); + expect(resolveContext).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/entitlements/test/shared.test.ts b/packages/entitlements/test/shared.test.ts new file mode 100644 index 0000000..c412eea --- /dev/null +++ b/packages/entitlements/test/shared.test.ts @@ -0,0 +1,205 @@ +import type { ChargebeeEntitlement } from "../src/shared"; +import { + assertTarget, + createEntitlementsSnapshot, + isSnapshotExpired, + parseEntitlementsSnapshot, + parseSerializedEntitlementsSnapshot, + resolveEntitlement, + serializeEntitlementsSnapshot, +} from "../src/shared"; + +const now = Date.UTC(2026, 0, 1); + +const snapshot = createEntitlementsSnapshot( + [ + { featureId: "switch-on", value: "true", isEnabled: true }, + { featureId: "switch-off", value: "false", isEnabled: true }, + { featureId: "switch-enabled", isEnabled: true }, + { featureId: "switch-available", value: "available", isEnabled: true }, + { featureId: "seats", value: "25", isEnabled: true }, + { featureId: "storage", value: "unlimited", isEnabled: true }, + { featureId: "support", value: "priority", isEnabled: true }, + { featureId: "disabled", value: "true", isEnabled: false }, + { + featureId: "expired", + value: "true", + isEnabled: true, + expiresAt: now / 1000 - 1, + }, + ], + 60_000, + now, +); + +describe("entitlement mapping", () => { + it("maps switch entitlements to booleans", () => { + expect( + resolveEntitlement(snapshot, "switch-on", false, "api"), + ).toMatchObject({ + value: true, + status: "granted", + source: "api", + entitlement: { featureId: "switch-on" }, + }); + expect( + resolveEntitlement(snapshot, "switch-off", true, "cache"), + ).toMatchObject({ value: false, status: "granted", source: "cache" }); + expect( + resolveEntitlement(snapshot, "switch-enabled", false, "api"), + ).toMatchObject({ value: true, status: "granted" }); + expect( + resolveEntitlement(snapshot, "switch-available", false, "api"), + ).toMatchObject({ value: true }); + }); + + it("maps numeric and unlimited entitlements", () => { + expect(resolveEntitlement(snapshot, "seats", 0, "store")).toMatchObject({ + value: 25, + status: "granted", + source: "store", + }); + expect(resolveEntitlement(snapshot, "storage", 0, "relay")).toMatchObject({ + value: Number.POSITIVE_INFINITY, + status: "granted", + }); + }); + + it("maps string and object entitlements", () => { + expect( + resolveEntitlement(snapshot, "support", "basic", "api"), + ).toMatchObject({ value: "priority", status: "granted" }); + expect( + resolveEntitlement>( + snapshot, + "support", + {}, + "api", + ).value, + ).toMatchObject({ featureId: "support", value: "priority" }); + }); + + it("takes the value's shape from the default value alone", () => { + expect(resolveEntitlement(snapshot, "switch-on", "off", "api").value).toBe( + "true", + ); + expect(resolveEntitlement(snapshot, "seats", "0", "api").value).toBe("25"); + expect( + resolveEntitlement>( + snapshot, + "seats", + {}, + "api", + ).value, + ).toMatchObject({ featureId: "seats", value: "25" }); + }); + + it("fails closed for disabled, expired, missing, and mismatched values", () => { + expect(resolveEntitlement(snapshot, "disabled", false, "api")).toMatchObject( + { value: false, status: "disabled" }, + ); + expect(resolveEntitlement(snapshot, "expired", false, "api")).toMatchObject({ + value: false, + status: "disabled", + }); + expect(resolveEntitlement(snapshot, "missing", false, "api")).toMatchObject({ + value: false, + status: "error", + error: { code: "not-found" }, + }); + expect(resolveEntitlement(snapshot, "support", false, "api")).toMatchObject({ + value: false, + error: { code: "type-mismatch" }, + }); + expect(resolveEntitlement(snapshot, "support", 0, "api")).toMatchObject({ + value: 0, + error: { code: "type-mismatch" }, + }); + expect( + resolveEntitlement(snapshot, "switch-enabled", "basic", "api"), + ).toMatchObject({ value: "basic", error: { code: "type-mismatch" } }); + }); +}); + +describe("target validation", () => { + it("reduces a target to the single identifier it evaluates against", () => { + expect(assertTarget({ customerId: "customer-1" })).toEqual({ + customerId: "customer-1", + }); + expect(assertTarget({ subscriptionId: "subscription-1" })).toEqual({ + subscriptionId: "subscription-1", + }); + }); + + it("ignores unrelated properties a caller's context carries", () => { + const context = { + userId: "app-user-1", + customerId: "customer-1", + plan: "pro", + }; + + expect(assertTarget(context)).toEqual({ customerId: "customer-1" }); + }); + + it("rejects an ambiguous target rather than guessing", () => { + expect(() => + assertTarget({ + customerId: "customer-1", + subscriptionId: "subscription-1", + } as never), + ).toThrow("not both"); + }); + + it("rejects a target with no usable identifier", () => { + expect(() => assertTarget({ userId: "app-user-1" } as never)).toThrow( + "requires a non-empty customerId or subscriptionId", + ); + expect(() => assertTarget({ customerId: "" } as never)).toThrow( + "requires a non-empty customerId or subscriptionId", + ); + }); +}); + +describe("snapshot parsing and serialization", () => { + it("serializes and parses a valid snapshot roundtrip", () => { + const serialized = serializeEntitlementsSnapshot(snapshot); + const parsed = parseSerializedEntitlementsSnapshot(serialized); + + expect(parsed).toEqual(snapshot); + }); + + it("identifies expired snapshots correctly", () => { + expect(isSnapshotExpired(snapshot, now)).toBe(false); + expect(isSnapshotExpired(snapshot, now + 70_000)).toBe(true); + }); + + it("throws on invalid snapshot structures", () => { + expect(() => parseEntitlementsSnapshot(null)).toThrow("expected an object"); + expect(() => parseEntitlementsSnapshot({})).toThrow("schemaVersion"); + expect(() => + parseEntitlementsSnapshot({ + schemaVersion: 1, + generatedAt: "invalid", + expiresAt: "invalid", + entitlements: {}, + }), + ).toThrow("generatedAt"); + expect(() => + parseEntitlementsSnapshot({ + schemaVersion: 1, + generatedAt: new Date().toISOString(), + expiresAt: new Date().toISOString(), + }), + ).toThrow("entitlements"); + expect(() => + parseEntitlementsSnapshot({ + schemaVersion: 1, + generatedAt: new Date().toISOString(), + expiresAt: new Date().toISOString(), + entitlements: { + bad: { featureId: "", isEnabled: true }, + }, + }), + ).toThrow("featureId"); + }); +}); diff --git a/packages/entitlements/test/web.test.ts b/packages/entitlements/test/web.test.ts new file mode 100644 index 0000000..2662dd5 --- /dev/null +++ b/packages/entitlements/test/web.test.ts @@ -0,0 +1,246 @@ +import { + createEntitlementsSnapshot, + Feature, + setDefaultEntitlements, +} from "../src/shared"; +import { ChargebeeEntitlements } from "../src/web"; + +afterEach(() => { + setDefaultEntitlements(undefined); +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +const snapshotWith = (sso: string) => + createEntitlementsSnapshot( + [{ featureId: "sso", value: sso, isEnabled: true }], + 60_000, + ); + +describe("ChargebeeEntitlements (web)", () => { + it("loads a relay snapshot and evaluates synchronously", async () => { + const fetchImplementation = vi.fn(async () => + Response.json(snapshotWith("true")), + ); + const client = new ChargebeeEntitlements({ + relayUrl: "/api/entitlements", + fetchImplementation, + }); + + await client.initialize(); + + expect(client.get("sso", false)).toBe(true); + expect(client.getDetails("sso", false)).toMatchObject({ + value: true, + status: "granted", + source: "relay", + }); + expect(fetchImplementation).toHaveBeenCalledWith( + "/api/entitlements", + expect.objectContaining({ + cache: "no-store", + credentials: "same-origin", + }), + ); + }); + + it("calls the global fetch without binding it to the client", async () => { + // Browsers' native fetch throws "Illegal invocation" unless `this` is the window. + vi.stubGlobal( + "fetch", + async function (this: unknown) { + if (this !== undefined && this !== globalThis) { + throw new TypeError("Illegal invocation"); + } + return Response.json(snapshotWith("true")); + }, + ); + const client = new ChargebeeEntitlements({ relayUrl: "/api/entitlements" }); + + try { + await client.initialize(); + } finally { + vi.unstubAllGlobals(); + } + + expect(client.get("sso", false)).toBe(true); + }); + + it("reports pending before the snapshot loads", () => { + const client = new ChargebeeEntitlements({ + relayUrl: "/api/entitlements", + fetchImplementation: async () => Response.json(snapshotWith("true")), + }); + + expect(client.getDetails("sso", false)).toEqual({ + value: false, + status: "pending", + }); + }); + + it("fails closed when the relay snapshot expires", async () => { + const snapshot = createEntitlementsSnapshot( + [{ featureId: "sso", value: "true", isEnabled: true }], + 500, + Date.now() - 1_000, + ); + const onSnapshotExpired = vi.fn(); + const client = new ChargebeeEntitlements({ + relayUrl: "/api/entitlements", + fetchImplementation: async () => Response.json(snapshot), + onSnapshotExpired, + }); + await client.initialize(); + + expect(client.getDetails("sso", false)).toMatchObject({ + value: false, + status: "stale", + }); + expect(onSnapshotExpired).toHaveBeenCalledTimes(1); + }); + + it("reports expiry once while the relay keeps serving an expired snapshot", async () => { + const snapshot = createEntitlementsSnapshot( + [{ featureId: "sso", value: "true", isEnabled: true }], + 500, + Date.now() - 1_000, + ); + const fetchImplementation = vi.fn(async () => Response.json(snapshot)); + const onSnapshotExpired = vi.fn(); + const client = new ChargebeeEntitlements({ + relayUrl: "/api/entitlements", + fetchImplementation, + onSnapshotExpired, + }); + await client.initialize(); + + client.get("sso", false); + await vi.waitFor(() => expect(fetchImplementation).toHaveBeenCalledTimes(2)); + await new Promise((resolve) => setTimeout(resolve, 0)); + client.get("sso", false); + + expect(onSnapshotExpired).toHaveBeenCalledTimes(1); + }); + + it("discards a relay response that lands after close", async () => { + const pending = deferred(); + const client = new ChargebeeEntitlements({ + relayUrl: "/api/entitlements", + fetchImplementation: () => pending.promise, + }); + + const loading = client.initialize(); + await client.close(); + pending.resolve(Response.json(snapshotWith("true"))); + await loading; + + expect(client.getDetails("sso", false)).toMatchObject({ + status: "pending", + }); + }); + + it("keeps the new subject's snapshot when an old response lands after reset", async () => { + const pending = deferred(); + const fetchImplementation = vi + .fn() + .mockResolvedValueOnce(Response.json(snapshotWith("false"))) + .mockReturnValueOnce(pending.promise) + .mockResolvedValueOnce(Response.json(snapshotWith("true"))); + const client = new ChargebeeEntitlements({ + relayUrl: "/api/entitlements", + fetchImplementation, + }); + await client.initialize(); + + const oldRefresh = client.refreshSnapshot(); + await client.reset(); + pending.resolve(Response.json(snapshotWith("false"))); + await oldRefresh; + + expect(client.get("sso", false)).toBe(true); + }); + + it("refreshes its snapshot on reset", async () => { + const fetchImplementation = vi + .fn() + .mockResolvedValueOnce(Response.json(snapshotWith("false"))) + .mockResolvedValueOnce(Response.json(snapshotWith("true"))); + const onSnapshotRefreshed = vi.fn(); + const client = new ChargebeeEntitlements({ + relayUrl: "/api/entitlements", + fetchImplementation, + onSnapshotRefreshed, + }); + + await client.initialize(); + expect(client.get("sso", true)).toBe(false); + expect(onSnapshotRefreshed).not.toHaveBeenCalled(); + + await client.reset(); + expect(client.get("sso", false)).toBe(true); + expect(onSnapshotRefreshed).toHaveBeenCalledWith( + expect.objectContaining({ changedFeatureIds: ["sso"] }), + ); + }); + + it("does not retain the previous subject's snapshot after a failed reset", async () => { + const fetchImplementation = vi + .fn() + .mockResolvedValueOnce(Response.json(snapshotWith("true"))) + .mockResolvedValueOnce( + Response.json({ error: "Unauthorized" }, { status: 401 }), + ); + const onError = vi.fn(); + const client = new ChargebeeEntitlements({ + relayUrl: "/api/entitlements", + fetchImplementation, + onError, + }); + + await client.initialize(); + await expect(client.reset()).rejects.toThrow("HTTP 401"); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("HTTP 401") }), + { operation: "refresh" }, + ); + expect(client.getDetails("sso", false)).toMatchObject({ + value: false, + status: "pending", + }); + }); + + it("evaluates Feature instances without requiring a target", async () => { + const snapshot = createEntitlementsSnapshot( + [ + { featureId: "sso", value: "true", isEnabled: true }, + { featureId: "seats", value: "10", isEnabled: true }, + ], + 60_000, + ); + const client = new ChargebeeEntitlements({ + relayUrl: "/api/entitlements", + fetchImplementation: async () => Response.json(snapshot), + }); + await client.initialize(); + + // Bound client via client.feature() + const ssoFeature = client.feature("sso", false); + const seatsFeature = client.feature("seats", 0); + + expect(await ssoFeature.get()).toBe(true); + expect(await seatsFeature.get()).toBe(10); + // @ts-expect-error A web feature takes no target. + expect(await seatsFeature.get({ customerId: "customer-1" })).toBe(10); + + // Global client via setDefaultEntitlements(client) + setDefaultEntitlements(client); + const standaloneFeature = new Feature("seats", 0); + expect(await standaloneFeature.get()).toBe(10); + }); +}); diff --git a/packages/entitlements/tsconfig.json b/packages/entitlements/tsconfig.json new file mode 100644 index 0000000..b6e3391 --- /dev/null +++ b/packages/entitlements/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "strict": true, + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false, + "incremental": true, + "noErrorTruncation": true, + "composite": true, + "declaration": true, + "emitDeclarationOnly": true, + "lib": ["esnext", "dom", "dom.iterable"], + "types": ["node", "vitest/globals"], + "outDir": "./dist", + "declarationDir": "./dist" + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["**/dist/**", "**/node_modules/**"] +} diff --git a/packages/entitlements/tsdown.config.ts b/packages/entitlements/tsdown.config.ts new file mode 100644 index 0000000..36e88a6 --- /dev/null +++ b/packages/entitlements/tsdown.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + dts: { build: true, incremental: true }, + format: ["esm"], + entry: { + index: "./src/index.ts", + cache: "./src/cache/index.ts", + server: "./src/server/index.ts", + web: "./src/web/index.ts", + nextjs: "./src/nextjs.ts", + }, + external: ["chargebee", "ioredis", "next", "next/server", "server-only"], + sourcemap: true, +}); diff --git a/packages/entitlements/vitest.config.ts b/packages/entitlements/vitest.config.ts new file mode 100644 index 0000000..a42cb69 --- /dev/null +++ b/packages/entitlements/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + clearMocks: true, + globals: true, + benchmark: { + include: ["bench/**/*.bench.ts"], + }, + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + exclude: [ + "node_modules/**", + "dist/**", + "test/**", + "**/*.d.ts", + "vitest.config.ts", + "tsdown.config.ts", + ], + }, + }, +}); diff --git a/packages/openfeature/CHANGELOG.md b/packages/openfeature/CHANGELOG.md new file mode 100644 index 0000000..5a68bab --- /dev/null +++ b/packages/openfeature/CHANGELOG.md @@ -0,0 +1,99 @@ +# Changelog + +## Unreleased + +- The OpenFeature mapping moved here from `@chargebee/entitlements`, which now + returns SDK-agnostic `EntitlementDetails`. Provider results are unchanged: + `status` and `error.code` map to the same reasons, error codes, variants, + and `flagMetadata` as before. The root re-exports `EntitlementDetails`, + `EntitlementStatus`, and `EntitlementErrorCode` in place of + `EntitlementResolution`. + - **Breaking:** `ChargebeeEntitlementsWebProvider.client` is now + `.entitlements`, matching the server provider. +- Extracted all framework/SDK-agnostic logic (snapshot caching, background + refresh, Chargebee loading, evaluation, the browser relay) into a new + package, [`@chargebee/entitlements`](https://github.com/chargebee/js-framework-adapters/blob/main/packages/entitlements/README.md). + `@chargebee/openfeature` is now a thin adapter that implements OpenFeature's + `Provider` interface over `@chargebee/entitlements`'s `ChargebeeEntitlements` + (server) and `ChargebeeEntitlementsWebClient` (web) clients. Use + `@chargebee/entitlements` directly if you don't need an OpenFeature SDK. + - **Breaking:** removed the `/cache` and `/nextjs` subpaths, and the + `chargebee`, `ioredis`, and `next` peer dependencies — import + `createMemoryEntitlementsCache`/`createRedisEntitlementsCache` from + `@chargebee/entitlements/cache` and `createEntitlementsRelayHandler` from + `@chargebee/entitlements/nextjs` (or `/server`) instead. + - **Breaking:** `ChargebeeEntitlementsProvider` and + `ChargebeeEntitlementsWebProvider` no longer implement snapshot/cache + logic themselves; construct a `ChargebeeEntitlements` / + `ChargebeeEntitlementsWebClient` and pass it as `{ entitlements }` (or + keep passing the same options — the provider constructs one for you) and + read it back via the new `.entitlements` / `.client` properties. + - The default cache-key namespace moved with the cache module and changed + from `chargebee:openfeature:v1` to `chargebee:entitlements:v1`; pass + `cacheNamespace` explicitly if you need to keep reading previously cached + keys. +- Both providers resolve all four flag types through the single + `getValue(featureId, defaultValue, target)` method on the underlying client, + since the default value's runtime type already determines how the entitlement + is parsed. The `resolve*Evaluation` methods no longer take OpenFeature's + per-call `logger`; pass a `logger` to `ChargebeeEntitlements` instead. +- The server provider reads `customerId` or `subscriptionId` off the + evaluation context, replacing the removed `chargebeeCustomerId` / + `chargebeeSubscriptionId` / `chargebeeEvaluationMode` keys. A context with + both identifiers, or neither, resolves to `INVALID_CONTEXT`. +- Replaced the tiered cache with a `cache` slot in front of a `durableStore` + slot on the server provider; both accept any `EntitlementsCache`. +- Added `refreshOnMiss: "background"` so a missing snapshot resolves to caller + defaults with reason `STALE` while it loads, plus `onSnapshotRefreshed` and + `onError` callbacks. +- Added explicit `refreshSnapshot`, `writeSnapshot`, and `deleteSnapshot`, + replacing `invalidate`, `evictSnapshot`, and `purgeSnapshot`. +- Stored snapshots are now served past `expiresAt` while refreshing in the + background instead of failing the evaluation. +- The memory and Redis cache adapters take their own `ttlMs` option (60s + default) so cache expiry can be configured where the cache is created; + `cacheTtlMs` on the provider now overrides it per write when set. +- `refreshSnapshot` evicts the cached snapshot before fetching from Chargebee, + so a webhook-driven refresh cannot be shadowed by stale cached values. +- Explicit primes now supersede request refreshes already in flight instead of + reusing a fetch that may have started before the webhook change. +- Renamed the `EntitlementsCache` interface to `EntitlementsStorage` since the + same contract also backs the durable `store` slot, not just the cache. +- Renamed the `EntitlementCacheSource` type to `SnapshotSource`, since it + enumerates every place a snapshot can come from (`api`, `cache`, `store`, + `relay`), not only cache sources. +- Replaced the `MemoryEntitlementsCache` class with a `createMemoryEntitlementsCache` + factory, matching `createRedisEntitlementsCache`. +- Renamed `createChargebeeEntitlementsHandler` (`@chargebee/openfeature/nextjs`) + to `createEntitlementsRelayHandler`, matching the generic handler of the same + name on `@chargebee/openfeature/server`. Removed the redundant + `createChargebeeEntitlementsRoute` — return `createEntitlementsRelayHandler(...)` + directly as your route's `GET` export. +- `createEntitlementsRelayHandler` on `@chargebee/openfeature/server` is now + generic over the request type, so the Next.js entry point instantiates it + for `NextRequest` instead of duplicating its request-handling logic. +- `createRedisEntitlementsCache` now takes an `ioredis` client directly instead + of a hand-adapted `get`/`set`/`delete` object. `ioredis` is a new optional + peer dependency. Other Redis-compatible clients (e.g. Upstash) should + implement `EntitlementsStorage` directly. +- `@chargebee/openfeature/server` and `@chargebee/openfeature/web` no longer + re-export shared domain types (`ChargebeeTarget`, `ChargebeeEntitlement`, + `EntitlementResolution`, etc.). Import these from the root + `@chargebee/openfeature` package, which has no peer-dependency + requirements. This removes three independently drifting copies of the same + export list in favor of one canonical source. The root entry point no longer + re-exports snapshot helpers or context utilities either; import those from + `@chargebee/entitlements/server` and `@chargebee/entitlements/cache`. +- `@chargebee/openfeature/nextjs` no longer re-exports `ChargebeeEntitlementsProvider` + / `ChargebeeEntitlementsProviderOptions`; import those from + `@chargebee/openfeature/server`, where the provider is actually constructed. +- Moved each subpath's barrel file into its module folder as `index.ts` + (e.g. `src/cache.ts` → `src/cache/index.ts`) so there is a single canonical + entry point per module instead of a flat file alongside its own folder. + Purely internal; the published subpaths are unchanged. + +## 0.1.0 + +- Initial Chargebee Entitlements providers for the OpenFeature server and web SDKs. +- Secure framework-neutral and Next.js 16 entitlement relay helpers. +- Tiered in-memory and client-agnostic Redis caching. diff --git a/packages/openfeature/LICENSE.md b/packages/openfeature/LICENSE.md new file mode 100644 index 0000000..fce48db --- /dev/null +++ b/packages/openfeature/LICENSE.md @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2025 - present, Chargebee + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/packages/openfeature/README.md b/packages/openfeature/README.md new file mode 100644 index 0000000..550c923 --- /dev/null +++ b/packages/openfeature/README.md @@ -0,0 +1,138 @@ +# Chargebee OpenFeature provider + +`@chargebee/openfeature` adapts +[`@chargebee/entitlements`](https://github.com/chargebee/js-framework-adapters/blob/main/packages/entitlements/README.md)'s +framework-agnostic Chargebee entitlements client to the +[OpenFeature](https://openfeature.dev) server and web SDKs. It supports +customer-level consolidated entitlements, subscription-level entitlements, and +Next.js 16 browser evaluation. + +All caching, snapshot resolution, background refresh, and evaluation logic +lives in `@chargebee/entitlements`. This package only implements OpenFeature's +`Provider` interface on top of it — see that package's README for cache, +store, background-refresh, and browser-relay setup. + +## Install + +```sh +pnpm add @chargebee/openfeature @chargebee/entitlements chargebee @openfeature/server-sdk +pnpm add @openfeature/web-sdk +``` + +For React client components, also install `@openfeature/react-sdk`. + +## Server provider + +Pass an initialized Chargebee client to the provider — the same options +`ChargebeeEntitlements` takes. Chargebee credentials remain entirely in server +code. + +```ts +import { OpenFeature } from "@openfeature/server-sdk"; +import Chargebee from "chargebee"; +import { ChargebeeEntitlementsProvider } from "@chargebee/openfeature/server"; + +const chargebee = new Chargebee({ + site: process.env.CHARGEBEE_SITE!, + apiKey: process.env.CHARGEBEE_API_KEY!, +}); + +export const entitlementsProvider = new ChargebeeEntitlementsProvider({ + chargebeeClient: chargebee, +}); + +await OpenFeature.setProviderAndWait(entitlementsProvider); +``` + +Evaluate Chargebee feature IDs as OpenFeature flag keys. The provider reads +`customerId` or `subscriptionId` off the evaluation context; `targetingKey` +remains the application's subject ID and is never assumed to be a Chargebee ID. + +```ts +const enabled = await OpenFeature.getClient().getBooleanValue( + "advanced-reports", + false, + { + targetingKey: session.user.id, + customerId: session.user.chargebeeCustomerId, + }, +); +``` + +A context carrying both `customerId` and `subscriptionId`, or neither, +resolves to the caller's default with `INVALID_CONTEXT`. + +### Sharing a `ChargebeeEntitlements` instance + +If you also need to evaluate entitlements outside of OpenFeature — for example +from a relay route, or a plain server action — construct the +`@chargebee/entitlements` client yourself and pass it in, instead of passing +raw options. The provider exposes it back as `.entitlements`, so all three call +sites share one cache and one set of in-flight requests: + +```ts +import { ChargebeeEntitlements } from "@chargebee/entitlements/server"; +import { createEntitlementsRelayHandler } from "@chargebee/entitlements/nextjs"; +import { ChargebeeEntitlementsProvider } from "@chargebee/openfeature/server"; + +export const entitlements = new ChargebeeEntitlements({ + chargebeeClient: chargebee, + cache, + durableStore: postgresSnapshotStore, +}); + +export const entitlementsProvider = new ChargebeeEntitlementsProvider({ + entitlements, +}); + +// app/api/entitlements/route.ts +export const GET = createEntitlementsRelayHandler({ entitlements, resolveContext }); +``` + +See `@chargebee/entitlements`'s README for cache/store configuration, +background refresh (`refreshOnMiss: "background"`), `refreshSnapshot` / +`deleteSnapshot`, and entitlement-to-flag-type mapping — the provider forwards +its constructor options to `ChargebeeEntitlements` unchanged. + +## Next.js 16 browser relay + +The OpenFeature Web SDK evaluates synchronously and cannot call Chargebee +directly because the Chargebee API key is secret. The web provider loads a +sanitized snapshot from an authenticated application endpoint, served by +`@chargebee/entitlements/nextjs` (see its README for the route handler). + +Register the browser provider: + +```tsx +"use client"; + +import { + OpenFeature, + OpenFeatureProvider, +} from "@openfeature/react-sdk"; +import { ChargebeeEntitlementsWebProvider } from "@chargebee/openfeature/web"; + +const provider = new ChargebeeEntitlementsWebProvider({ + relayUrl: "/api/entitlements", +}); + +OpenFeature.setProvider(provider); + +export function FeatureProvider({ children }: { children: React.ReactNode }) { + return {children}; +} +``` + +Browser billing identity always comes from `resolveContext` on the relay's +server route, not from browser OpenFeature context — the browser provider's +`onContextChange` only triggers a re-fetch. For subscription-scoped browser +access, have the authenticated callback select and authorize the subscription +from server session state. Use separate relay URLs/OpenFeature domains when a +page needs independent snapshots for multiple subscriptions. + +## Operational notes + +- Call `OpenFeature.close()` during long-lived process shutdown; it calls + through to `entitlements.close()`. +- See `@chargebee/entitlements`'s README for entitlement mapping, snapshot + freshness, and Chargebee pagination details — they apply unchanged here. diff --git a/packages/openfeature/package.json b/packages/openfeature/package.json new file mode 100644 index 0000000..30638fd --- /dev/null +++ b/packages/openfeature/package.json @@ -0,0 +1,102 @@ +{ + "name": "@chargebee/openfeature", + "author": "DX Chargebee", + "version": "0.1.0", + "type": "module", + "description": "OpenFeature provider for Chargebee", + "license": "MIT", + "homepage": "https://github.com/chargebee/js-framework-adapters/blob/main/packages/openfeature/README.md", + "repository": { + "type": "git", + "url": "git@github.com:chargebee/js-framework-adapters.git", + "directory": "packages/openfeature" + }, + "keywords": [ + "chargebee", + "openfeature", + "entitlements", + "feature-flags" + ], + "engines": { + "node": ">=22" + }, + "main": "dist/index.mjs", + "module": "dist/index.mjs", + "types": "dist/index.d.mts", + "exports": { + ".": { + "dev-source": "./src/index.ts", + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "./server": { + "dev-source": "./src/server/index.ts", + "types": "./dist/server.d.mts", + "default": "./dist/server.mjs" + }, + "./web": { + "dev-source": "./src/web/index.ts", + "types": "./dist/web.d.mts", + "default": "./dist/web.mjs" + } + }, + "typesVersions": { + "*": { + "*": [ + "./dist/index.d.mts" + ], + "server": [ + "./dist/server.d.mts" + ], + "web": [ + "./dist/web.d.mts" + ] + } + }, + "files": [ + "dist", + "README.md", + "LICENSE.md", + "CHANGELOG.md" + ], + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "typecheck": "tsc --build tsconfig.json", + "test": "vitest run", + "test:watch": "vitest", + "coverage": "vitest run --coverage", + "lint:package": "publint run --strict", + "lint:types": "attw --profile esm-only --pack ." + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@chargebee/entitlements": "workspace:*" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", + "@openfeature/server-sdk": "^1.23.0", + "@openfeature/web-sdk": "^1.10.0", + "@types/node": "22.15.2", + "@vitest/coverage-v8": "^4.0.18", + "chargebee": "^3.30.0", + "publint": "^0.3.23", + "tsdown": "^0.20.3", + "typescript": "^5.9.2", + "vitest": "^4.0.18" + }, + "peerDependencies": { + "@openfeature/server-sdk": "^1.23.0", + "@openfeature/web-sdk": "^1.10.0" + }, + "peerDependenciesMeta": { + "@openfeature/server-sdk": { + "optional": true + }, + "@openfeature/web-sdk": { + "optional": true + } + } +} diff --git a/packages/openfeature/src/index.ts b/packages/openfeature/src/index.ts new file mode 100644 index 0000000..4ff1a1e --- /dev/null +++ b/packages/openfeature/src/index.ts @@ -0,0 +1,8 @@ +export type { + ChargebeeEntitlement, + ChargebeeTarget, + EntitlementDetails, + EntitlementErrorCode, + EntitlementStatus, + Logger, +} from "@chargebee/entitlements"; diff --git a/packages/openfeature/src/resolution.ts b/packages/openfeature/src/resolution.ts new file mode 100644 index 0000000..c132711 --- /dev/null +++ b/packages/openfeature/src/resolution.ts @@ -0,0 +1,158 @@ +import type { + EntitlementDetails, + EntitlementErrorCode, +} from "@chargebee/entitlements"; + +/** + * How a `pending` status (no snapshot loaded yet) surfaces. The server SDK + * evaluates per request, so a pending snapshot is stale data; the web SDK + * models it as a provider that is not ready. + */ +export enum PendingAs { + Stale = "stale", + NotReady = "not-ready", +} + +type FlagMetadata = Record; + +/** Structurally the `ResolutionDetails` both OpenFeature SDKs expect. */ +export interface OpenFeatureResolution { + value: T; + variant?: string; + reason?: string; + errorCode?: TErrorCode; + errorMessage?: string; + flagMetadata?: FlagMetadata; +} + +/** OpenFeature's `StandardResolutionReasons`, shared by both SDKs. */ +const Reason = { + TargetingMatch: "TARGETING_MATCH", + Cached: "CACHED", + Disabled: "DISABLED", + Stale: "STALE", + Error: "ERROR", +} as const; + +/** OpenFeature's `ErrorCode` values, shared by both SDKs. */ +const ERROR_CODES: Record = { + "not-found": "FLAG_NOT_FOUND", + "type-mismatch": "TYPE_MISMATCH", + "invalid-target": "INVALID_CONTEXT", + unavailable: "GENERAL", +}; +const PROVIDER_NOT_READY = "PROVIDER_NOT_READY"; + +/** `granted` values become a variant, e.g. `true` → `enabled`, `Infinity` → `unlimited`. */ +function variantFor(details: EntitlementDetails): string | undefined { + if (details.status === "disabled") { + return "disabled"; + } + + if (details.status !== "granted") { + return undefined; + } + + const { value } = details; + if (typeof value === "boolean") { + return value ? "enabled" : "disabled"; + } + + if (value === Number.POSITIVE_INFINITY) { + return "unlimited"; + } + + if (typeof value === "string" || typeof value === "number") { + return String(value); + } + + return details.entitlement?.value ?? "enabled"; +} + +/** Flattens the Chargebee entitlement into OpenFeature's primitive-only metadata. */ +function metadataFor( + details: EntitlementDetails, +): FlagMetadata | undefined { + const { entitlement, source } = details; + if (!entitlement) { + return undefined; + } + + const optional: Record = { + chargebeeValue: entitlement.value, + chargebeeFeatureType: entitlement.featureType, + chargebeeFeatureUnit: entitlement.featureUnit, + chargebeeOverridden: entitlement.isOverridden, + chargebeeExpiresAt: entitlement.expiresAt, + cacheSource: source, + unlimited: details.value === Number.POSITIVE_INFINITY ? true : undefined, + }; + + return { + chargebeeFeatureId: entitlement.featureId, + chargebeeEnabled: entitlement.isEnabled, + ...(Object.fromEntries( + Object.entries(optional).filter(([, value]) => value !== undefined), + ) as FlagMetadata), + }; +} + +function reasonFor(details: EntitlementDetails): string { + switch (details.status) { + case "granted": + return details.source === "api" ? Reason.TargetingMatch : Reason.Cached; + case "disabled": + return Reason.Disabled; + case "stale": + case "pending": + return Reason.Stale; + case "error": + return Reason.Error; + } +} + +/** + * Maps SDK-agnostic {@link EntitlementDetails} onto OpenFeature's resolution + * shape. The server and web SDKs declare structurally identical but + * nominally distinct `ErrorCode` enums, so the target error code type is a + * generic parameter instead of a hard dependency on either SDK package. + */ +export function toResolutionDetails( + details: EntitlementDetails, + pendingAs: PendingAs, +): OpenFeatureResolution { + const { value } = details; + + if (details.status === "pending" && pendingAs === PendingAs.NotReady) { + return { + value, + reason: Reason.Error, + errorCode: PROVIDER_NOT_READY as TErrorCode, + errorMessage: "Chargebee entitlement snapshot is not loaded", + }; + } + + if (details.status === "pending") { + return { + value, + reason: Reason.Stale, + flagMetadata: { snapshotPending: true }, + }; + } + + if (details.error) { + return { + value, + reason: Reason.Error, + errorCode: ERROR_CODES[details.error.code] as TErrorCode, + errorMessage: details.error.message, + }; + } + + return { + value, + variant: variantFor(details), + reason: reasonFor(details), + flagMetadata: metadataFor(details), + }; +} diff --git a/packages/openfeature/src/server/index.ts b/packages/openfeature/src/server/index.ts new file mode 100644 index 0000000..6c8b211 --- /dev/null +++ b/packages/openfeature/src/server/index.ts @@ -0,0 +1,4 @@ +export { + ChargebeeEntitlementsProvider, + type ChargebeeEntitlementsProviderOptions, +} from "./provider"; diff --git a/packages/openfeature/src/server/provider.ts b/packages/openfeature/src/server/provider.ts new file mode 100644 index 0000000..d93c7fd --- /dev/null +++ b/packages/openfeature/src/server/provider.ts @@ -0,0 +1,108 @@ +import type { ChargebeeTarget } from "@chargebee/entitlements"; +import { + ChargebeeEntitlements, + type ChargebeeEntitlementsOptions, +} from "@chargebee/entitlements/server"; +import type { + ErrorCode, + EvaluationContext, + JsonValue, + Provider, + ResolutionDetails, +} from "@openfeature/server-sdk"; +import { PendingAs, toResolutionDetails } from "../resolution"; + +export type ChargebeeEntitlementsProviderOptions = + | ChargebeeEntitlementsOptions + | { entitlements: ChargebeeEntitlements }; + +/** + * An OpenFeature evaluation context is an untyped bag, so the Chargebee + * identifiers are picked out of it here. `ChargebeeEntitlements` validates + * what comes back and reports a missing or ambiguous target as + * `invalid-target`, surfaced here as `INVALID_CONTEXT`. + */ +const targetFrom = (context: EvaluationContext): ChargebeeTarget => + ({ + customerId: context.customerId, + subscriptionId: context.subscriptionId, + }) as unknown as ChargebeeTarget; + +/** + * Adapts `@chargebee/entitlements`'s framework-agnostic `ChargebeeEntitlements` + * client to the OpenFeature server `Provider` interface. All caching, + * snapshot resolution, and evaluation logic lives in `ChargebeeEntitlements` + * (exposed here as `.entitlements`) — this class only translates method + * names and return shapes. + * + * Pass either the same options `ChargebeeEntitlements` takes, or + * `{ entitlements }` to reuse an instance you already constructed elsewhere + * (e.g. to share it with a relay route or a direct, non-OpenFeature call site). + */ +export class ChargebeeEntitlementsProvider implements Provider { + readonly metadata = { name: "Chargebee Entitlements" } as const; + readonly runsOn = "server" as const; + readonly entitlements: ChargebeeEntitlements; + + constructor(options: ChargebeeEntitlementsProviderOptions) { + this.entitlements = + "entitlements" in options + ? options.entitlements + : new ChargebeeEntitlements(options); + } + + onClose(): Promise { + return this.entitlements.close(); + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + ): Promise> { + return this.resolve(flagKey, defaultValue, context); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context: EvaluationContext, + ): Promise> { + return this.resolve(flagKey, defaultValue, context); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context: EvaluationContext, + ): Promise> { + return this.resolve(flagKey, defaultValue, context); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + ): Promise> { + return this.resolve(flagKey, defaultValue, context); + } + + /** + * One evaluation path for all four flag types: the default value's runtime + * type already tells `getDetails` which shape to parse the entitlement into. + */ + private async resolve( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + ): Promise> { + return toResolutionDetails( + await this.entitlements.getDetails( + flagKey, + defaultValue, + targetFrom(context), + ), + PendingAs.Stale, + ); + } +} diff --git a/packages/openfeature/src/web/index.ts b/packages/openfeature/src/web/index.ts new file mode 100644 index 0000000..811ef19 --- /dev/null +++ b/packages/openfeature/src/web/index.ts @@ -0,0 +1,4 @@ +export { + ChargebeeEntitlementsWebProvider, + type ChargebeeEntitlementsWebProviderOptions, +} from "./provider"; diff --git a/packages/openfeature/src/web/provider.ts b/packages/openfeature/src/web/provider.ts new file mode 100644 index 0000000..42daa9f --- /dev/null +++ b/packages/openfeature/src/web/provider.ts @@ -0,0 +1,116 @@ +import { + ChargebeeEntitlements, + type ChargebeeEntitlementsOptions, +} from "@chargebee/entitlements/web"; +import { + type ErrorCode, + type EvaluationContext, + type JsonValue, + OpenFeatureEventEmitter, + type Provider, + ProviderEvents, + type ResolutionDetails, +} from "@openfeature/web-sdk"; +import { PendingAs, toResolutionDetails } from "../resolution"; + +export type ChargebeeEntitlementsWebProviderOptions = Omit< + ChargebeeEntitlementsOptions, + "onSnapshotExpired" | "onSnapshotRefreshed" | "onError" +>; + +/** + * Adapts `@chargebee/entitlements/web`'s framework-agnostic + * `ChargebeeEntitlements` to the OpenFeature web `Provider` interface. All + * relay-fetch and evaluation logic lives in `ChargebeeEntitlements` (exposed + * here as `.entitlements`) — this class only bridges its callbacks to + * OpenFeature's event emitter. + */ +export class ChargebeeEntitlementsWebProvider implements Provider { + readonly metadata = { name: "Chargebee Entitlements" } as const; + readonly runsOn = "client" as const; + readonly events = new OpenFeatureEventEmitter(); + readonly entitlements: ChargebeeEntitlements; + + constructor(options: ChargebeeEntitlementsWebProviderOptions) { + this.entitlements = new ChargebeeEntitlements({ + ...options, + onSnapshotExpired: () => { + this.events.emit(ProviderEvents.Stale, { + message: "Chargebee entitlement snapshot expired", + }); + }, + onSnapshotRefreshed: ({ changedFeatureIds }) => { + if (changedFeatureIds.length === 0) { + return; + } + + this.events.emit(ProviderEvents.ConfigurationChanged, { + flagsChanged: changedFeatureIds, + }); + }, + onError: (error) => { + this.events.emit(ProviderEvents.Error, { + message: + error instanceof Error + ? error.message + : "Unable to refresh Chargebee entitlements", + }); + }, + }); + } + + initialize(): Promise { + return this.entitlements.initialize(); + } + + onContextChange( + _oldContext: EvaluationContext, + _newContext: EvaluationContext, + ): Promise { + return this.entitlements.reset(); + } + + onClose(): Promise { + return this.entitlements.close(); + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + ): ResolutionDetails { + return this.resolve(flagKey, defaultValue); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + ): ResolutionDetails { + return this.resolve(flagKey, defaultValue); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + ): ResolutionDetails { + return this.resolve(flagKey, defaultValue); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + ): ResolutionDetails { + return this.resolve(flagKey, defaultValue); + } + + /** + * One evaluation path for all four flag types: the default value's runtime + * type already tells `getDetails` which shape to parse the entitlement into. + * The relay snapshot is scoped to the session, so the context is unused. + */ + private resolve(flagKey: string, defaultValue: T): ResolutionDetails { + return toResolutionDetails( + this.entitlements.getDetails(flagKey, defaultValue), + PendingAs.NotReady, + ); + } +} diff --git a/packages/openfeature/test/server.test.ts b/packages/openfeature/test/server.test.ts new file mode 100644 index 0000000..c735c44 --- /dev/null +++ b/packages/openfeature/test/server.test.ts @@ -0,0 +1,207 @@ +import { + ChargebeeEntitlements, + type ChargebeeEntitlementsOptions, +} from "@chargebee/entitlements/server"; +import { OpenFeature } from "@openfeature/server-sdk"; +import { ChargebeeEntitlementsProvider } from "../src/server"; + +function makeClient() { + const customerRequest = vi.fn(async () => ({ + list: [ + { + customer_entitlement: { + customer_id: "customer-1", + feature_id: "sso", + value: "true", + is_enabled: true, + }, + }, + ], + })); + const client = { + customerEntitlement: { entitlementsForCustomer: customerRequest }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: vi.fn(async () => ({ + list: [], + })), + }, + } as unknown as ChargebeeEntitlementsOptions["chargebeeClient"]; + return { client, customerRequest }; +} + +const context = { + targetingKey: "app-user-1", + customerId: "customer-1", +}; + +afterEach(async () => { + await OpenFeature.clearProviders(); +}); + +describe("ChargebeeEntitlementsProvider", () => { + it("constructs its own ChargebeeEntitlements from options and works through the OpenFeature server SDK", async () => { + const { client } = makeClient(); + const provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: client, + }); + + expect(provider.entitlements).toBeInstanceOf(ChargebeeEntitlements); + await OpenFeature.setProviderAndWait(provider); + + await expect( + OpenFeature.getClient().getBooleanValue("sso", false, context), + ).resolves.toBe(true); + }); + + it("wraps an existing ChargebeeEntitlements instance instead of creating a new one", async () => { + const { client } = makeClient(); + const entitlements = new ChargebeeEntitlements({ chargebeeClient: client }); + const provider = new ChargebeeEntitlementsProvider({ entitlements }); + + expect(provider.entitlements).toBe(entitlements); + await expect( + provider.resolveBooleanEvaluation("sso", false, context), + ).resolves.toMatchObject({ value: true, reason: "TARGETING_MATCH" }); + }); + + it("evaluates string, number, and object flags", async () => { + const customerRequest = vi.fn(async () => ({ + list: [ + { + customer_entitlement: { + customer_id: "customer-1", + feature_id: "seats", + value: "10", + is_enabled: true, + }, + }, + ], + })); + const provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: { + customerEntitlement: { entitlementsForCustomer: customerRequest }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: vi.fn(), + }, + } as unknown as ChargebeeEntitlementsOptions["chargebeeClient"], + }); + + await expect( + provider.resolveNumberEvaluation("seats", 0, context), + ).resolves.toMatchObject({ value: 10 }); + await expect( + provider.resolveStringEvaluation("seats", "0", context), + ).resolves.toMatchObject({ value: "10" }); + await expect( + provider.resolveObjectEvaluation("seats", {}, context), + ).resolves.toMatchObject({ value: { featureId: "seats", value: "10" } }); + }); + + it("maps entitlement details to OpenFeature variants and metadata", async () => { + const { client } = makeClient(); + const provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: client, + }); + + await expect( + provider.resolveBooleanEvaluation("sso", false, context), + ).resolves.toEqual({ + value: true, + variant: "enabled", + reason: "TARGETING_MATCH", + flagMetadata: { + chargebeeFeatureId: "sso", + chargebeeEnabled: true, + chargebeeValue: "true", + cacheSource: "api", + }, + }); + await expect( + provider.resolveBooleanEvaluation("missing", false, context), + ).resolves.toMatchObject({ + value: false, + reason: "ERROR", + errorCode: "FLAG_NOT_FOUND", + }); + await expect( + provider.resolveNumberEvaluation("sso", 0, context), + ).resolves.toMatchObject({ value: 0, errorCode: "TYPE_MISMATCH" }); + }); + + it("resolves a subscription-scoped context", async () => { + const subscriptionRequest = vi.fn(async () => ({ + list: [ + { + subscription_entitlement: { + subscription_id: "subscription-1", + feature_id: "seats", + value: "50", + is_enabled: true, + }, + }, + ], + })); + const provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: { + customerEntitlement: { entitlementsForCustomer: vi.fn() }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: subscriptionRequest, + }, + } as unknown as ChargebeeEntitlementsOptions["chargebeeClient"], + }); + + await expect( + provider.resolveNumberEvaluation("seats", 0, { + targetingKey: "app-user-1", + subscriptionId: "subscription-1", + }), + ).resolves.toMatchObject({ value: 50 }); + }); + + it("passes through STALE with snapshotPending metadata while a background refresh loads", async () => { + const { client } = makeClient(); + const provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: client, + refreshOnMiss: "background", + }); + + await expect( + provider.resolveBooleanEvaluation("sso", false, context), + ).resolves.toEqual({ + value: false, + reason: "STALE", + flagMetadata: { snapshotPending: true }, + }); + }); + + it("passes through INVALID_CONTEXT without calling Chargebee", async () => { + const { client, customerRequest } = makeClient(); + const provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: client, + }); + + await expect( + provider.resolveBooleanEvaluation("sso", false, { + targetingKey: "app-user-1", + }), + ).resolves.toMatchObject({ value: false, errorCode: "INVALID_CONTEXT" }); + await expect( + provider.resolveBooleanEvaluation("sso", false, { + customerId: "customer-1", + subscriptionId: "subscription-1", + }), + ).resolves.toMatchObject({ value: false, errorCode: "INVALID_CONTEXT" }); + expect(customerRequest).not.toHaveBeenCalled(); + }); + + it("delegates onClose to the wrapped entitlements client", async () => { + const { client } = makeClient(); + const entitlements = new ChargebeeEntitlements({ chargebeeClient: client }); + const close = vi.spyOn(entitlements, "close"); + const provider = new ChargebeeEntitlementsProvider({ entitlements }); + + await provider.onClose(); + + expect(close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/openfeature/test/web.test.ts b/packages/openfeature/test/web.test.ts new file mode 100644 index 0000000..cb0129a --- /dev/null +++ b/packages/openfeature/test/web.test.ts @@ -0,0 +1,127 @@ +import { createEntitlementsSnapshot } from "@chargebee/entitlements/server"; +import { OpenFeature, ProviderEvents } from "@openfeature/web-sdk"; +import { ChargebeeEntitlementsWebProvider } from "../src/web"; + +afterEach(async () => { + await OpenFeature.clearProviders(); +}); + +describe("ChargebeeEntitlementsWebProvider", () => { + it("loads a relay snapshot and evaluates synchronously through the web SDK", async () => { + const snapshot = createEntitlementsSnapshot( + [{ featureId: "sso", value: "true", isEnabled: true }], + 60_000, + ); + const fetchImplementation = vi.fn(async () => Response.json(snapshot)); + const provider = new ChargebeeEntitlementsWebProvider({ + relayUrl: "/api/entitlements", + fetchImplementation, + }); + + await OpenFeature.setProviderAndWait(provider); + + expect(OpenFeature.getClient().getBooleanValue("sso", false)).toBe(true); + expect(fetchImplementation).toHaveBeenCalledWith( + "/api/entitlements", + expect.objectContaining({ + cache: "no-store", + credentials: "same-origin", + }), + ); + }); + + it("emits Stale and falls back to the default while the relay snapshot expires", async () => { + const snapshot = createEntitlementsSnapshot( + [{ featureId: "sso", value: "true", isEnabled: true }], + 500, + Date.now() - 1_000, + ); + const provider = new ChargebeeEntitlementsWebProvider({ + relayUrl: "/api/entitlements", + fetchImplementation: async () => Response.json(snapshot), + }); + const onStale = vi.fn(); + provider.events.addHandler(ProviderEvents.Stale, onStale); + await provider.initialize(); + + expect(provider.resolveBooleanEvaluation("sso", false)).toMatchObject({ + value: false, + reason: "STALE", + }); + expect(onStale).toHaveBeenCalledTimes(1); + }); + + it("emits ConfigurationChanged and refreshes its snapshot on context change", async () => { + const first = createEntitlementsSnapshot( + [{ featureId: "sso", value: "false", isEnabled: true }], + 60_000, + ); + const second = createEntitlementsSnapshot( + [{ featureId: "sso", value: "true", isEnabled: true }], + 60_000, + ); + const fetchImplementation = vi + .fn() + .mockResolvedValueOnce(Response.json(first)) + .mockResolvedValueOnce(Response.json(second)); + const provider = new ChargebeeEntitlementsWebProvider({ + relayUrl: "/api/entitlements", + fetchImplementation, + }); + const onConfigurationChanged = vi.fn(); + provider.events.addHandler( + ProviderEvents.ConfigurationChanged, + onConfigurationChanged, + ); + + await provider.initialize(); + expect(provider.resolveBooleanEvaluation("sso", true).value).toBe(false); + await provider.onContextChange({}, { targetingKey: "new-user" }); + expect(provider.resolveBooleanEvaluation("sso", false).value).toBe(true); + expect(onConfigurationChanged).toHaveBeenCalledWith( + expect.objectContaining({ flagsChanged: ["sso"] }), + ); + }); + + it("does not retain the previous subject's snapshot after a failed context change", async () => { + const snapshot = createEntitlementsSnapshot( + [{ featureId: "sso", value: "true", isEnabled: true }], + 60_000, + ); + const fetchImplementation = vi + .fn() + .mockResolvedValueOnce(Response.json(snapshot)) + .mockResolvedValueOnce( + Response.json({ error: "Unauthorized" }, { status: 401 }), + ); + const provider = new ChargebeeEntitlementsWebProvider({ + relayUrl: "/api/entitlements", + fetchImplementation, + }); + + await provider.initialize(); + await expect( + provider.onContextChange( + { targetingKey: "first-user" }, + { targetingKey: "second-user" }, + ), + ).rejects.toThrow("HTTP 401"); + expect(provider.resolveBooleanEvaluation("sso", false)).toMatchObject({ + value: false, + errorCode: "PROVIDER_NOT_READY", + }); + }); + + it("delegates onClose to the wrapped client", async () => { + const provider = new ChargebeeEntitlementsWebProvider({ + relayUrl: "/api/entitlements", + fetchImplementation: async () => + Response.json(createEntitlementsSnapshot([], 60_000)), + }); + const close = vi.spyOn(provider.entitlements, "close"); + + await provider.onClose(); + + expect(close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/openfeature/tsconfig.json b/packages/openfeature/tsconfig.json new file mode 100644 index 0000000..ef26f8b --- /dev/null +++ b/packages/openfeature/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "strict": true, + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false, + "incremental": true, + "noErrorTruncation": true, + "composite": true, + "declaration": true, + "emitDeclarationOnly": true, + "lib": ["esnext", "dom", "dom.iterable"], + "types": ["node", "vitest/globals"], + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "@chargebee/entitlements": ["../entitlements/src/index.ts"], + "@chargebee/entitlements/server": ["../entitlements/src/server/index.ts"], + "@chargebee/entitlements/web": ["../entitlements/src/web/index.ts"] + } + }, + "references": [{ "path": "../entitlements" }], + "include": ["src/**/*", "test/**/*"], + "exclude": ["**/dist/**", "**/node_modules/**"] +} diff --git a/packages/openfeature/tsdown.config.ts b/packages/openfeature/tsdown.config.ts new file mode 100644 index 0000000..7491c0f --- /dev/null +++ b/packages/openfeature/tsdown.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + dts: { build: true, incremental: true }, + format: ["esm"], + entry: { + index: "./src/index.ts", + server: "./src/server/index.ts", + web: "./src/web/index.ts", + }, + external: [ + "@chargebee/entitlements", + "@chargebee/entitlements/server", + "@chargebee/entitlements/web", + "@openfeature/server-sdk", + "@openfeature/web-sdk", + ], + sourcemap: true, +}); diff --git a/packages/openfeature/vitest.config.ts b/packages/openfeature/vitest.config.ts new file mode 100644 index 0000000..4e7b161 --- /dev/null +++ b/packages/openfeature/vitest.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + ssr: { + resolve: { + // `dev-source` precedes Vite's SSR defaults so `@chargebee/entitlements` + // resolves to its sources and the tests run without building it first. + conditions: ["dev-source", "module", "node", "development|production"], + }, + }, + test: { + clearMocks: true, + globals: true, + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + exclude: [ + "node_modules/**", + "dist/**", + "test/**", + "**/*.d.ts", + "vitest.config.ts", + "tsdown.config.ts", + ], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d64494b..4991f97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,8 +6,10 @@ settings: overrides: chargebee-init: link:packages/cli - '@chargebee/nextjs': link:packages/nextjs '@chargebee/express': link:packages/express + '@chargebee/nextjs': link:packages/nextjs + '@chargebee/entitlements': link:packages/entitlements + '@chargebee/openfeature': link:packages/openfeature importers: @@ -31,6 +33,9 @@ importers: '@better-auth/utils': specifier: ^0.3.1 version: 0.3.1 + '@chargebee/entitlements': + specifier: link:../entitlements + version: link:../entitlements zod: specifier: ^4.3.6 version: 4.3.6 @@ -43,7 +48,7 @@ importers: version: 4.0.18(vitest@4.0.18(@types/node@22.15.2)(jiti@2.6.1)) better-auth: specifier: ^1.5.3 - version: 1.5.3(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)))(mongodb@7.1.0)(mysql2@3.15.3)(next@15.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@4.0.18(@types/node@22.15.2)(jiti@2.6.1)) + version: 1.5.3(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(@types/pg@8.23.1)(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)))(mongodb@7.1.0)(mysql2@3.15.3)(next@16.3.0(@types/node@22.15.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.23.1)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@4.0.18(@types/node@22.15.2)(jiti@2.6.1)) better-call: specifier: ^1.1.8 version: 1.1.8(zod@4.3.6) @@ -55,7 +60,7 @@ importers: version: 3.23.1 tsdown: specifier: ^0.20.1 - version: 0.20.3(typescript@5.9.2) + version: 0.20.3(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.2) typescript: specifier: ^5.7.3 version: 5.9.2 @@ -79,7 +84,7 @@ importers: version: 7.7.2 simple-git: specifier: ^3.27.0 - version: 3.27.0 + version: 3.27.0(supports-color@7.2.0) devDependencies: '@pnpm/types': specifier: ^1000.6.0 @@ -97,6 +102,49 @@ importers: specifier: ^3.25.20 version: 3.25.20 + packages/entitlements: + dependencies: + server-only: + specifier: ^0.0.1 + version: 0.0.1 + devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.18.5 + version: 0.18.5 + '@types/node': + specifier: 22.15.2 + version: 22.15.2 + '@types/pg': + specifier: ^8.23.1 + version: 8.23.1 + '@vitest/coverage-v8': + specifier: ^5.0.5 + version: 5.0.5(vitest@5.0.5) + chargebee: + specifier: ^3.30.0 + version: 3.30.0 + ioredis: + specifier: ^6.0.0 + version: 6.0.0(supports-color@7.2.0) + next: + specifier: ^16.3.0 + version: 16.3.0(@types/node@22.15.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + pg: + specifier: ^8.23.1 + version: 8.23.1 + publint: + specifier: ^0.3.23 + version: 0.3.23 + tsdown: + specifier: ^0.20.3 + version: 0.20.3(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.2) + typescript: + specifier: ^5.9.2 + version: 5.9.2 + vitest: + specifier: ^5.0.5 + version: 5.0.5(@types/node@22.15.2)(@vitest/coverage-v8@5.0.5)(vite@7.3.1(@types/node@22.15.2)(jiti@2.6.1)) + packages/express: dependencies: chargebee: @@ -111,7 +159,7 @@ importers: version: 5.0.2 express: specifier: ^5.1.0 - version: 5.1.0 + version: 5.1.0(supports-color@7.2.0) packages/nextjs: dependencies: @@ -126,8 +174,57 @@ importers: specifier: ^15.3.3 version: 15.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + packages/openfeature: + dependencies: + '@chargebee/entitlements': + specifier: link:../entitlements + version: link:../entitlements + devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.18.5 + version: 0.18.5 + '@openfeature/server-sdk': + specifier: ^1.23.0 + version: 1.23.0(@openfeature/core@1.12.0) + '@openfeature/web-sdk': + specifier: ^1.10.0 + version: 1.10.0(@openfeature/core@1.12.0) + '@types/node': + specifier: 22.15.2 + version: 22.15.2 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@types/node@22.15.2)(jiti@2.6.1)) + chargebee: + specifier: ^3.30.0 + version: 3.30.0 + publint: + specifier: ^0.3.23 + version: 0.3.23 + tsdown: + specifier: ^0.20.3 + version: 0.20.3(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.2) + typescript: + specifier: ^5.9.2 + version: 5.9.2 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.15.2)(jiti@2.6.1) + packages: + '@andrewbranch/untar.js@1.0.4': + resolution: {integrity: sha512-pVXSwPsLuw8IGLo2Di0EaOfsk+ntVvpkk942J/sHYIkwvtKUakEcPh7HBgZ6tuimgzKSEHgCvO4XgQ05DEbwDw==} + + '@arethetypeswrong/cli@0.18.5': + resolution: {integrity: sha512-gM+8vRsQOD/Uc7EnBedUhkG5OCsDWE4uoak5QvomGpMpaky0Eh41p04nIMgrWb8EOmqZUJGc6zz9hsP6E56R7g==} + engines: {node: '>=20'} + hasBin: true + + '@arethetypeswrong/core@0.18.5': + resolution: {integrity: sha512-9ytjzGwxjm9Uz7I9avfbt5vlQt6uk9uRRESzJjqrznl6WKvI6dwYTo+vJ3U02Wrq/mR3iql/PzhvHhKdJIAjDQ==} + engines: {node: '>=20'} + '@babel/generator@8.0.0-rc.1': resolution: {integrity: sha512-3ypWOOiC4AYHKr8vYRVtWtWmyvcoItHtVqF8paFax+ydpmUdPsJpLBkBBs5ItmhdrwC3a0ZSqqFAdzls4ODP3w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -136,6 +233,10 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.0-rc.1': resolution: {integrity: sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -144,6 +245,10 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.0-rc.1': resolution: {integrity: sha512-I4YnARytXC2RzkLNVnf5qFNFMzp679qZpmtw/V3Jt2uGnWiIxyJtaukjG7R8pSx8nG2NamICpGfljQsogj+FbQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -153,6 +258,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/parser@8.0.0-rc.1': resolution: {integrity: sha512-6HyyU5l1yK/7h9Ki52i5h6mDAx4qJdiLQO4FdCyJNoB/gy3T3GGJdhQzzbZgvgZCugYBvwtQiWRt94QKedHnkA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -162,6 +272,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@babel/types@8.0.0-rc.1': resolution: {integrity: sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -230,24 +344,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.2.5': resolution: {integrity: sha512-5DjiiDfHqGgR2MS9D+AZ8kOfrzTGqLKywn8hoXpXXlJXIECGQ32t+gt/uiS2XyGBM2XQhR6ztUvbjZWeccFMoQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.2.5': resolution: {integrity: sha512-AVqLCDb/6K7aPNIcxHaTQj01sl1m989CJIQFQEaiQkGr2EQwyOpaATJ473h+nXDUuAcREhccfRpe/tu+0wu0eQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.2.5': resolution: {integrity: sha512-fq9meKm1AEXeAWan3uCg6XSP5ObA6F/Ovm89TwaMiy1DNIwdgxPkNwxlXJX8iM6oRbFysYeGnT0OG8diCWb9ew==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.2.5': resolution: {integrity: sha512-xaOIad4wBambwJa6mdp1FigYSIF9i7PCqRbvBqtIi9y29QtPVQ13sDGtUnsRoe6SjL10auMzQ6YAe+B3RpZXVg==} @@ -261,6 +379,9 @@ packages: cpu: [x64] os: [win32] + '@braidai/lang@1.1.2': + resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} + '@chevrotain/cst-dts-gen@10.5.0': resolution: {integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==} @@ -273,6 +394,10 @@ packages: '@chevrotain/utils@10.5.0': resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==} + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + '@electric-sql/pglite-socket@0.0.20': resolution: {integrity: sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==} hasBin: true @@ -290,6 +415,9 @@ packages: '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} @@ -458,116 +586,294 @@ packages: peerDependencies: hono: ^4 + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + '@img/sharp-darwin-arm64@0.34.1': resolution: {integrity: sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.34.1': resolution: {integrity: sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + '@img/sharp-libvips-darwin-arm64@1.1.0': resolution: {integrity: sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.1.0': resolution: {integrity: sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.1.0': resolution: {integrity: sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==} cpu: [arm64] os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.1.0': resolution: {integrity: sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==} cpu: [arm] os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.1.0': resolution: {integrity: sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==} cpu: [ppc64] os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.1.0': resolution: {integrity: sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==} cpu: [s390x] os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.1.0': resolution: {integrity: sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==} cpu: [x64] os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.1.0': resolution: {integrity: sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==} cpu: [arm64] os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.1.0': resolution: {integrity: sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==} cpu: [x64] os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.1': resolution: {integrity: sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.1': resolution: {integrity: sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.1': resolution: {integrity: sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.1': resolution: {integrity: sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.1': resolution: {integrity: sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.1': resolution: {integrity: sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.1': resolution: {integrity: sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-ia32@0.34.1': resolution: {integrity: sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.34.1': resolution: {integrity: sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@ioredis/commands@2.0.0': + resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -578,6 +884,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -587,6 +896,9 @@ packages: '@kwsites/promise-deferred@1.1.1': resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@loaderkit/resolve@1.0.6': + resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + '@mongodb-js/saslprep@1.4.6': resolution: {integrity: sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==} @@ -600,41 +912,88 @@ packages: '@next/env@15.3.3': resolution: {integrity: sha512-OdiMrzCl2Xi0VTjiQQUK0Xh7bJHnOuET2s+3V+Y40WJBAXrJeGA3f+I8MZJ/YQ3mVGi5XGR1L66oFlgqXhQ4Vw==} + '@next/env@16.3.0': + resolution: {integrity: sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==} + '@next/swc-darwin-arm64@15.3.3': resolution: {integrity: sha512-WRJERLuH+O3oYB4yZNVahSVFmtxRNjNF1I1c34tYMoJb0Pve+7/RaLAJJizyYiFhjYNGHRAE1Ri2Fd23zgDqhg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] + '@next/swc-darwin-arm64@16.3.0': + resolution: {integrity: sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@next/swc-darwin-x64@15.3.3': resolution: {integrity: sha512-XHdzH/yBc55lu78k/XwtuFR/ZXUTcflpRXcsu0nKmF45U96jt1tsOZhVrn5YH+paw66zOANpOnFQ9i6/j+UYvw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] + '@next/swc-darwin-x64@16.3.0': + resolution: {integrity: sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@next/swc-linux-arm64-gnu@15.3.3': resolution: {integrity: sha512-VZ3sYL2LXB8znNGcjhocikEkag/8xiLgnvQts41tq6i+wql63SMS1Q6N8RVXHw5pEUjiof+II3HkDd7GFcgkzw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-gnu@16.3.0': + resolution: {integrity: sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.3.3': resolution: {integrity: sha512-h6Y1fLU4RWAp1HPNJWDYBQ+e3G7sLckyBXhmH9ajn8l/RSMnhbuPBV/fXmy3muMcVwoJdHL+UtzRzs0nXOf9SA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] + + '@next/swc-linux-arm64-musl@16.3.0': + resolution: {integrity: sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.3.3': resolution: {integrity: sha512-jJ8HRiF3N8Zw6hGlytCj5BiHyG/K+fnTKVDEKvUCyiQ/0r5tgwO7OgaRiOjjRoIx2vwLR+Rz8hQoPrnmFbJdfw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-gnu@16.3.0': + resolution: {integrity: sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.3.3': resolution: {integrity: sha512-HrUcTr4N+RgiiGn3jjeT6Oo208UT/7BuTr7K0mdKRBtTbT4v9zJqCDKO97DUqqoBK1qyzP1RwvrWTvU6EPh/Cw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] + + '@next/swc-linux-x64-musl@16.3.0': + resolution: {integrity: sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.3.3': resolution: {integrity: sha512-SxorONgi6K7ZUysMtRF3mIeHC5aA3IQLmKFQzU0OuhuUYwpOBc1ypaLJLP5Bf3M9k53KUUUj4vTPwzGvl/NwlQ==} @@ -642,12 +1001,24 @@ packages: cpu: [arm64] os: [win32] + '@next/swc-win32-arm64-msvc@16.3.0': + resolution: {integrity: sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@next/swc-win32-x64-msvc@15.3.3': resolution: {integrity: sha512-4QZG6F8enl9/S2+yIiOiju0iCTFd93d8VC1q9LZS4p/Xuk81W2QDjCFeoogmrWWkAD59z8ZxepBQap2dKS5ruw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] + '@next/swc-win32-x64-msvc@16.3.0': + resolution: {integrity: sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@noble/ciphers@2.1.1': resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} engines: {node: '>= 20.19.0'} @@ -656,6 +1027,20 @@ packages: resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} + '@openfeature/core@1.12.0': + resolution: {integrity: sha512-7PCPzyd1OC19begz30+CRknFB0ChtyaZUhk7YWrk+Bov1fpVw0HYkTXtoxxsvPfDZB9P9WsT7uixN+Z8f9+bcA==} + + '@openfeature/server-sdk@1.23.0': + resolution: {integrity: sha512-JWeLvltJIV0AFgOfbw7hK9b9Rw/5wWq+RMhCIU9sJtAqarxr8hSPglG+JAGGhYdElAz3Qwe9W3ApWyPNkQEyqg==} + engines: {node: '>=20'} + peerDependencies: + '@openfeature/core': ^1.12.0 + + '@openfeature/web-sdk@1.10.0': + resolution: {integrity: sha512-qf+JmJSnaslhegNwoDDLn2Cf72P6cIobuAQPUb61MSkzJOZuVLU6f9pv/90FlLcK6UDtp6od//xZHW712rBrmg==} + peerDependencies: + '@openfeature/core': ^1.12.0 + '@oxc-project/types@0.112.0': resolution: {integrity: sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ==} @@ -715,6 +1100,10 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + '@publint/pack@0.1.6': + resolution: {integrity: sha512-3uVNyGcVplhPZSLVyeIpL7+cIRn1YCSNHLG/rUIlBQMVH8YuN9++YF+5+UDIIO9RW98dujiUoTltO7RDB5bFJA==} + engines: {node: '>=18'} + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -753,24 +1142,28 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.3': resolution: {integrity: sha512-Z03/wrqau9Bicfgb3Dbs6SYTHliELk2PM2LpG2nFd+cGupTMF5kanLEcj2vuuJLLhptNyS61rtk7SOZ+lPsTUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.3': resolution: {integrity: sha512-iSXXZsQp08CSilff/DCTFZHSVEpEwdicV3W8idHyrByrcsRDVh9sGC3sev6d8BygSGj3vt8GvUKBPCoyMA4tgQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.3': resolution: {integrity: sha512-qaj+MFudtdCv9xZo9znFvkgoajLdc+vwf0Kz5N44g+LU5XMe+IsACgn3UG7uTRlCCvhMAGXm1XlpEA5bZBrOcw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.3': resolution: {integrity: sha512-U662UnMETyjT65gFmG9ma+XziENrs7BBnENi/27swZPYagubfHRirXHG2oMl+pEax2WvO7Kb9gHZmMakpYqBHQ==} @@ -832,66 +1225,79 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -923,6 +1329,10 @@ packages: cpu: [x64] os: [win32] + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + '@sindresorhus/tsconfig@7.0.0': resolution: {integrity: sha512-i5K04hLAP44Af16zmDjG07E1NHuDgCM07SJAT4gY0LZSRrWYzwt4qkLem6TIbIVh0k51RkN2bF+lP+lM5eC9fw==} engines: {node: '>=18'} @@ -972,6 +1382,9 @@ packages: '@types/node@22.15.2': resolution: {integrity: sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A==} + '@types/pg@8.23.1': + resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} + '@types/qs@6.9.18': resolution: {integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==} @@ -1005,9 +1418,26 @@ packages: '@vitest/browser': optional: true + '@vitest/coverage-v8@5.0.5': + resolution: {integrity: sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==} + peerDependencies: + '@vitest/browser': 5.0.5 + vitest: 5.0.5 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/istanbul-lib-coverage@1.0.2': + resolution: {integrity: sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-report@1.0.2': + resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==} + engines: {node: '>=22'} + '@vitest/mocker@4.0.18': resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} peerDependencies: @@ -1019,6 +1449,17 @@ packages: vite: optional: true + '@vitest/mocker@5.0.5': + resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} + peerDependencies: + msw: ^2.4.9 || ^3.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@4.0.18': resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} @@ -1031,6 +1472,9 @@ packages: '@vitest/spy@4.0.18': resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@5.0.5': + resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} @@ -1042,14 +1486,29 @@ packages: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1061,6 +1520,9 @@ packages: ast-v8-to-istanbul@0.3.11: resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + ast-v8-to-istanbul@1.0.7: + resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} + aws-ssl-profiles@1.1.2: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} @@ -1068,6 +1530,11 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.11.13: + resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==} + engines: {node: '>=6.0.0'} + hasBin: true + better-auth@1.5.3: resolution: {integrity: sha512-E+9kA9GMX1+gT3FfMCqRz0NufT4X/+tNhpOsHW1jLmyPZKinkHtfZkUffSBnG5qGkvfBaH/slT5c1fKttnmF5w==} peerDependencies: @@ -1214,6 +1681,22 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + chargebee@3.10.0: resolution: {integrity: sha512-Rny1w8cRXMfGbV4vxpGcsz9RsdLhUtrdpTisQASgqoZW0C3Ff4KVi2wCOEltc+536cqMqDml57s0X9bQRoDVVQ==} engines: {node: '>=18.*'} @@ -1222,6 +1705,15 @@ packages: resolution: {integrity: sha512-NNBgGLLjmhDU1ZGdvSha6zU1VU4NCd5P1hZxRdlZxVxivmN4/ZioCibXZw8aP+nwNCJC57LwhOFDgvFCHpF0IQ==} engines: {node: '>=18.*'} + chargebee@3.30.0: + resolution: {integrity: sha512-E/oFgqhgV6lpDoBfGekyas2y7tRHrLVIrbHE8p3DVG1kI96WNFTQRQ6cYcgHecPKIn1v+Q0m+1B0ndM+TcG8vA==} + engines: {node: '>=18.*'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + chevrotain@10.5.0: resolution: {integrity: sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==} @@ -1238,9 +1730,28 @@ packages: citty@0.2.1: resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cli-highlight@2.1.11: + resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1255,6 +1766,10 @@ packages: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} @@ -1294,6 +1809,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -1324,6 +1848,10 @@ packages: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -1439,6 +1967,12 @@ packages: effect@3.18.4: resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emojilib@2.4.0: + resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} + empathic@2.0.0: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} @@ -1454,6 +1988,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1465,6 +2003,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1474,6 +2015,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -1492,6 +2037,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + express@5.1.0: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} @@ -1512,6 +2061,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -1545,6 +2097,10 @@ packages: generate-function@2.3.1: resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1591,6 +2147,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + hono@4.11.4: resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==} engines: {node: '>=16.9.0'} @@ -1629,6 +2188,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ioredis@6.0.0: + resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==} + engines: {node: '>=20.0.0'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -1636,6 +2199,10 @@ packages: is-arrayish@0.3.2: resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -1686,6 +2253,10 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru.min@1.1.4: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} @@ -1693,13 +2264,30 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + magicast@0.5.2: resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + marked-terminal@7.3.0: + resolution: {integrity: sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==} + engines: {node: '>=16.0.0'} + peerDependencies: + marked: '>=1 <16' + + marked@9.1.6: + resolution: {integrity: sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==} + engines: {node: '>= 16'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1768,6 +2356,10 @@ packages: socks: optional: true + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1775,6 +2367,9 @@ packages: resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} engines: {node: '>= 8.0'} + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + named-placeholders@1.1.6: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} @@ -1784,6 +2379,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanostores@1.1.1: resolution: {integrity: sha512-EYJqS25r2iBeTtGQCHidXl1VfZ1jXM7Q04zXJOrMlxVVmD0ptxJaNux92n1mJ7c5lN3zTq12MhH/8x59nP+qmg==} engines: {node: ^20.0.0 || >=22.0.0} @@ -1817,10 +2417,35 @@ packages: sass: optional: true + next@16.3.0: + resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + node-abi@3.87.0: resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} engines: {node: '>=10'} + node-emoji@2.2.0: + resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} + engines: {node: '>=18'} + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -1829,6 +2454,10 @@ packages: engines: {node: '>=18'} hasBin: true + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -1836,6 +2465,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -1846,6 +2479,18 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + + parse5-htmlparser2-tree-adapter@6.0.1: + resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} + + parse5@5.1.1: + resolution: {integrity: sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==} + + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -1864,6 +2509,40 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + pg-cloudflare@1.4.1: + resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==} + + pg-connection-string@2.14.1: + resolution: {integrity: sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.16.1: + resolution: {integrity: sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.23.1: + resolution: {integrity: sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1871,6 +2550,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} @@ -1878,10 +2561,26 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + postgres@3.4.7: resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} engines: {node: '>=12'} @@ -1889,6 +2588,7 @@ packages: prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true prisma@7.4.2: @@ -1911,6 +2611,11 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + publint@0.3.23: + resolution: {integrity: sha512-5MQipUPcB7MWw84zLUkHrg/H/UBtk3LL+A0GngTTBSsiNJLQurMUaSIRG3edlOrRz4UFe0AOKK9TZdIWviV+jQ==} + engines: {node: '>=18'} + hasBin: true + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -1960,12 +2665,20 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + regexp-to-ast@0.5.0: resolution: {integrity: sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==} remeda@2.33.4: resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -2009,6 +2722,10 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -2023,8 +2740,8 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -2039,6 +2756,9 @@ packages: resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -2052,6 +2772,15 @@ packages: resolution: {integrity: sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2098,6 +2827,10 @@ packages: simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + skin-tone@2.0.0: + resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} + engines: {node: '>=8'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2105,6 +2838,10 @@ packages: sparse-bitfield@3.0.3: resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sqlstring@2.3.3: resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} engines: {node: '>= 0.6'} @@ -2112,6 +2849,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -2119,10 +2859,17 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -2151,6 +2898,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -2158,21 +2909,48 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinybench@6.2.2: + resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} + engines: {node: '>=20.0.0'} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinyrainbow@3.0.3: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -2220,6 +2998,11 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + typescript@5.6.1-rc: + resolution: {integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==} + engines: {node: '>=14.17'} + hasBin: true + typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} @@ -2231,6 +3014,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unicode-emoji-modifier-base@1.0.0: + resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} + engines: {node: '>=4'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -2256,6 +3043,10 @@ packages: typescript: optional: true + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -2334,6 +3125,47 @@ packages: jsdom: optional: true + vitest@5.0.5: + resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.5 + '@vitest/browser-preview': 5.0.5 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.5 + '@vitest/coverage-v8': 5.0.5 + '@vitest/ui': 5.0.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -2352,9 +3184,34 @@ packages: engines: {node: '>=8'} hasBin: true + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs@16.2.2: + resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} + engines: {node: '>=10'} + zeptomatch@2.1.0: resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} @@ -2367,8 +3224,34 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: + '@andrewbranch/untar.js@1.0.4': {} + + '@arethetypeswrong/cli@0.18.5': + dependencies: + '@arethetypeswrong/core': 0.18.5 + chalk: 4.1.2 + cli-table3: 0.6.5 + commander: 10.0.1 + marked: 9.1.6 + marked-terminal: 7.3.0(marked@9.1.6) + semver: 7.8.5 + + '@arethetypeswrong/core@0.18.5': + dependencies: + '@andrewbranch/untar.js': 1.0.4 + '@loaderkit/resolve': 1.0.6 + cjs-module-lexer: 1.4.3 + fflate: 0.8.3 + lru-cache: 11.5.2 + semver: 7.8.5 + typescript: 5.6.1-rc + validate-npm-package-name: 5.0.1 + '@babel/generator@8.0.0-rc.1': dependencies: '@babel/parser': 8.0.0-rc.1 @@ -2380,16 +3263,24 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@8.0.0-rc.1': {} '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-identifier@8.0.0-rc.1': {} '@babel/parser@7.29.0': dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + '@babel/parser@8.0.0-rc.1': dependencies: '@babel/types': 8.0.0-rc.1 @@ -2399,6 +3290,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@8.0.0-rc.1': dependencies: '@babel/helper-string-parser': 8.0.0-rc.1 @@ -2484,6 +3380,8 @@ snapshots: '@biomejs/cli-win32-x64@2.2.5': optional: true + '@braidai/lang@1.1.2': {} + '@chevrotain/cst-dts-gen@10.5.0': dependencies: '@chevrotain/gast': 10.5.0 @@ -2503,6 +3401,9 @@ snapshots: '@chevrotain/utils@10.5.0': optional: true + '@colors/colors@1.5.0': + optional: true + '@electric-sql/pglite-socket@0.0.20(@electric-sql/pglite@0.3.15)': dependencies: '@electric-sql/pglite': 0.3.15 @@ -2522,6 +3423,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 @@ -2615,51 +3521,119 @@ snapshots: hono: 4.11.4 optional: true + '@img/colour@1.1.0': + optional: true + '@img/sharp-darwin-arm64@0.34.1': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.1.0 optional: true + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + '@img/sharp-darwin-x64@0.34.1': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.1.0 optional: true + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + '@img/sharp-libvips-darwin-arm64@1.1.0': optional: true + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + '@img/sharp-libvips-darwin-x64@1.1.0': optional: true + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + '@img/sharp-libvips-linux-arm64@1.1.0': optional: true + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + '@img/sharp-libvips-linux-arm@1.1.0': optional: true + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + '@img/sharp-libvips-linux-ppc64@1.1.0': optional: true + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + '@img/sharp-libvips-linux-s390x@1.1.0': optional: true + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + '@img/sharp-libvips-linux-x64@1.1.0': optional: true + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.1.0': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.1.0': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + '@img/sharp-linux-arm64@0.34.1': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.1.0 + '@img/sharp-libvips-linux-arm64': 1.1.0 + optional: true + + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + + '@img/sharp-linux-arm@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.1.0 + optional: true + + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 optional: true - '@img/sharp-linux-arm@0.34.1': + '@img/sharp-linux-riscv64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.1.0 + '@img/sharp-libvips-linux-riscv64': 1.3.2 optional: true '@img/sharp-linux-s390x@0.34.1': @@ -2667,32 +3641,73 @@ snapshots: '@img/sharp-libvips-linux-s390x': 1.1.0 optional: true + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + '@img/sharp-linux-x64@0.34.1': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.1.0 optional: true + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + '@img/sharp-linuxmusl-arm64@0.34.1': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.1.0 optional: true + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + '@img/sharp-linuxmusl-x64@0.34.1': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.1.0 optional: true + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + '@img/sharp-wasm32@0.34.1': dependencies: '@emnapi/runtime': 1.8.1 optional: true + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-win32-arm64@0.35.3': + optional: true + '@img/sharp-win32-ia32@0.34.1': optional: true + '@img/sharp-win32-ia32@0.35.3': + optional: true + '@img/sharp-win32-x64@0.34.1': optional: true + '@img/sharp-win32-x64@0.35.3': + optional: true + + '@ioredis/commands@2.0.0': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2702,19 +3717,25 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@kwsites/file-exists@1.1.1': + '@kwsites/file-exists@1.1.1(supports-color@7.2.0)': dependencies: - debug: 4.4.1 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color '@kwsites/promise-deferred@1.1.1': {} + '@loaderkit/resolve@1.0.6': + dependencies: + '@braidai/lang': 1.1.2 + '@mongodb-js/saslprep@1.4.6': dependencies: sparse-bitfield: 3.0.3 @@ -2729,40 +3750,76 @@ snapshots: '@napi-rs/wasm-runtime@1.1.1': dependencies: '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.8.1 + '@emnapi/runtime': 1.11.3 '@tybys/wasm-util': 0.10.1 optional: true '@next/env@15.3.3': {} + '@next/env@16.3.0': {} + '@next/swc-darwin-arm64@15.3.3': optional: true + '@next/swc-darwin-arm64@16.3.0': + optional: true + '@next/swc-darwin-x64@15.3.3': optional: true + '@next/swc-darwin-x64@16.3.0': + optional: true + '@next/swc-linux-arm64-gnu@15.3.3': optional: true + '@next/swc-linux-arm64-gnu@16.3.0': + optional: true + '@next/swc-linux-arm64-musl@15.3.3': optional: true + '@next/swc-linux-arm64-musl@16.3.0': + optional: true + '@next/swc-linux-x64-gnu@15.3.3': optional: true + '@next/swc-linux-x64-gnu@16.3.0': + optional: true + '@next/swc-linux-x64-musl@15.3.3': optional: true + '@next/swc-linux-x64-musl@16.3.0': + optional: true + '@next/swc-win32-arm64-msvc@15.3.3': optional: true + '@next/swc-win32-arm64-msvc@16.3.0': + optional: true + '@next/swc-win32-x64-msvc@15.3.3': optional: true + '@next/swc-win32-x64-msvc@16.3.0': + optional: true + '@noble/ciphers@2.1.1': {} '@noble/hashes@2.0.1': {} + '@openfeature/core@1.12.0': {} + + '@openfeature/server-sdk@1.23.0(@openfeature/core@1.12.0)': + dependencies: + '@openfeature/core': 1.12.0 + + '@openfeature/web-sdk@1.10.0(@openfeature/core@1.12.0)': + dependencies: + '@openfeature/core': 1.12.0 + '@oxc-project/types@0.112.0': {} '@pnpm/types@1000.6.0': {} @@ -2855,6 +3912,10 @@ snapshots: react-dom: 19.1.0(react@19.1.0) optional: true + '@publint/pack@0.1.6': + dependencies: + tinyexec: 1.3.0 + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -2977,6 +4038,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true + '@sindresorhus/is@4.6.0': {} + '@sindresorhus/tsconfig@7.0.0': {} '@standard-schema/spec@1.1.0': {} @@ -3033,6 +4096,12 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pg@8.23.1': + dependencies: + '@types/node': 22.15.2 + pg-protocol: 1.16.1 + pg-types: 2.2.0 + '@types/qs@6.9.18': {} '@types/range-parser@1.2.7': {} @@ -3077,6 +4146,18 @@ snapshots: tinyrainbow: 3.0.3 vitest: 4.0.18(@types/node@22.15.2)(jiti@2.6.1) + '@vitest/coverage-v8@5.0.5(vitest@5.0.5)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/istanbul-lib-coverage': 1.0.2 + '@vitest/istanbul-lib-report': 1.0.2 + ast-v8-to-istanbul: 1.0.7 + magicast: 0.5.5 + obug: 3.0.0 + std-env: 4.3.0 + tinyrainbow: 3.2.0 + vitest: 5.0.5(@types/node@22.15.2)(@vitest/coverage-v8@5.0.5)(vite@7.3.1(@types/node@22.15.2)(jiti@2.6.1)) + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -3086,6 +4167,12 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 + '@vitest/istanbul-lib-coverage@1.0.2': {} + + '@vitest/istanbul-lib-report@1.0.2': + dependencies: + '@vitest/istanbul-lib-coverage': 1.0.2 + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.15.2)(jiti@2.6.1))': dependencies: '@vitest/spy': 4.0.18 @@ -3094,6 +4181,15 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@22.15.2)(jiti@2.6.1) + '@vitest/mocker@5.0.5(vite@7.3.1(@types/node@22.15.2)(jiti@2.6.1))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.5 + estree-walker: 3.0.3 + magic-string: 1.4.3 + optionalDependencies: + vite: 7.3.1(@types/node@22.15.2)(jiti@2.6.1) + '@vitest/pretty-format@4.0.18': dependencies: tinyrainbow: 3.0.3 @@ -3111,6 +4207,8 @@ snapshots: '@vitest/spy@4.0.18': {} + '@vitest/spy@5.0.5': {} + '@vitest/utils@4.0.18': dependencies: '@vitest/pretty-format': 4.0.18 @@ -3123,10 +4221,22 @@ snapshots: ansi-colors@4.1.3: {} + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + ansis@4.2.0: {} + any-promise@1.3.0: {} + assertion-error@2.0.1: {} ast-kit@3.0.0-beta.1: @@ -3141,12 +4251,20 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + ast-v8-to-istanbul@1.0.7: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + aws-ssl-profiles@1.1.2: optional: true base64-js@1.5.1: {} - better-auth@1.5.3(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)))(mongodb@7.1.0)(mysql2@3.15.3)(next@15.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@4.0.18(@types/node@22.15.2)(jiti@2.6.1)): + baseline-browser-mapping@2.11.13: {} + + better-auth@1.5.3(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(@types/pg@8.23.1)(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)))(mongodb@7.1.0)(mysql2@3.15.3)(next@16.3.0(@types/node@22.15.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.23.1)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@4.0.18(@types/node@22.15.2)(jiti@2.6.1)): dependencies: '@better-auth/core': 1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/kysely-adapter': 1.5.3(@better-auth/core@1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11) @@ -3165,10 +4283,11 @@ snapshots: optionalDependencies: '@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2) better-sqlite3: 12.6.2 - drizzle-orm: 0.45.1(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)) + drizzle-orm: 0.45.1(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(@types/pg@8.23.1)(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)) mongodb: 7.1.0 mysql2: 3.15.3 - next: 15.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 16.3.0(@types/node@22.15.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + pg: 8.23.1 prisma: 7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -3211,11 +4330,11 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - body-parser@2.2.0: + body-parser@2.2.0(supports-color@7.2.0): dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.1 + debug: 4.4.3(supports-color@7.2.0) http-errors: 2.0.0 iconv-lite: 0.6.3 on-finished: 2.4.1 @@ -3271,10 +4390,25 @@ snapshots: chai@6.2.2: {} + chai@6.3.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + char-regex@1.0.2: {} + chargebee@3.10.0: {} chargebee@3.23.1: {} + chargebee@3.30.0: + dependencies: + zod: 4.4.3 + chevrotain@10.5.0: dependencies: '@chevrotain/cst-dts-gen': 10.5.0 @@ -3300,15 +4434,38 @@ snapshots: citty@0.2.1: optional: true + cjs-module-lexer@1.4.3: {} + + cli-highlight@2.1.11: + dependencies: + chalk: 4.1.2 + highlight.js: 10.7.3 + mz: 2.7.0 + parse5: 5.1.1 + parse5-htmlparser2-tree-adapter: 6.0.1 + yargs: 16.2.2 + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + client-only@0.0.1: {} + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cluster-key-slot@1.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 - optional: true - color-name@1.1.4: - optional: true + color-name@1.1.4: {} color-string@1.9.1: dependencies: @@ -3322,6 +4479,8 @@ snapshots: color-string: 1.9.1 optional: true + commander@10.0.1: {} + confbox@0.2.4: optional: true @@ -3348,9 +4507,17 @@ snapshots: csstype@3.2.3: optional: true - debug@4.4.1: + debug@4.4.1(supports-color@7.2.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 + + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 decompress-response@6.0.0: dependencies: @@ -3363,8 +4530,7 @@ snapshots: defu@6.1.4: {} - denque@2.1.0: - optional: true + denque@2.1.0: {} depd@2.0.0: {} @@ -3373,16 +4539,21 @@ snapshots: detect-libc@2.0.4: {} + detect-libc@2.1.2: + optional: true + dotenv@16.6.1: optional: true - drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)): + drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(@types/pg@8.23.1)(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)): optionalDependencies: '@electric-sql/pglite': 0.3.15 '@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2) + '@types/pg': 8.23.1 better-sqlite3: 12.6.2 kysely: 0.28.11 mysql2: 3.15.3 + pg: 8.23.1 postgres: 3.4.7 prisma: 7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2) optional: true @@ -3403,6 +4574,10 @@ snapshots: fast-check: 3.23.2 optional: true + emoji-regex@8.0.0: {} + + emojilib@2.4.0: {} + empathic@2.0.0: {} encodeurl@2.0.0: {} @@ -3416,12 +4591,16 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + environment@1.1.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} es-module-lexer@1.7.0: {} + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -3455,6 +4634,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + escalade@3.2.0: {} + escape-html@1.0.3: {} estree-walker@3.0.3: @@ -3467,19 +4648,21 @@ snapshots: expect-type@1.3.0: {} - express@5.1.0: + expect-type@1.4.0: {} + + express@5.1.0(supports-color@7.2.0): dependencies: accepts: 2.0.0 - body-parser: 2.2.0 + body-parser: 2.2.0(supports-color@7.2.0) content-disposition: 1.0.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.1 + debug: 4.4.1(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.0 + finalhandler: 2.1.0(supports-color@7.2.0) fresh: 2.0.0 http-errors: 2.0.0 merge-descriptors: 2.0.0 @@ -3490,9 +4673,9 @@ snapshots: proxy-addr: 2.0.7 qs: 6.14.0 range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.0 - serve-static: 2.2.0 + router: 2.2.0(supports-color@7.2.0) + send: 1.2.0(supports-color@7.2.0) + serve-static: 2.2.0(supports-color@7.2.0) statuses: 2.0.1 type-is: 2.0.1 vary: 1.1.2 @@ -3511,11 +4694,17 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fflate@0.8.3: {} + file-uri-to-path@1.0.0: {} - finalhandler@2.1.0: + finalhandler@2.1.0(supports-color@7.2.0): dependencies: - debug: 4.4.1 + debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -3546,6 +4735,8 @@ snapshots: is-property: 1.0.2 optional: true + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3602,6 +4793,8 @@ snapshots: dependencies: function-bind: 1.1.2 + highlight.js@10.7.3: {} + hono@4.11.4: optional: true @@ -3637,11 +4830,24 @@ snapshots: ini@1.3.8: {} + ioredis@6.0.0(supports-color@7.2.0): + dependencies: + '@ioredis/commands': 2.0.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3(supports-color@7.2.0) + denque: 2.1.0 + redis-errors: 1.2.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ipaddr.js@1.9.1: {} is-arrayish@0.3.2: optional: true + is-fullwidth-code-point@3.0.0: {} + is-promise@4.0.0: {} is-property@1.0.2: @@ -3683,6 +4889,8 @@ snapshots: long@5.3.2: optional: true + lru-cache@11.5.2: {} + lru.min@1.1.4: optional: true @@ -3690,15 +4898,38 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@1.4.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + magicast@0.5.2: dependencies: '@babel/parser': 7.29.0 '@babel/types': 7.29.0 source-map-js: 1.2.1 + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + make-dir@4.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 + + marked-terminal@7.3.0(marked@9.1.6): + dependencies: + ansi-escapes: 7.3.0 + ansi-regex: 6.2.2 + chalk: 5.6.2 + cli-highlight: 2.1.11 + cli-table3: 0.6.5 + marked: 9.1.6 + node-emoji: 2.2.0 + supports-hyperlinks: 3.2.0 + + marked@9.1.6: {} math-intrinsics@1.1.0: {} @@ -3736,6 +4967,8 @@ snapshots: mongodb-connection-string-url: 7.0.1 optional: true + mri@1.2.0: {} + ms@2.1.3: {} mysql2@3.15.3: @@ -3751,6 +4984,12 @@ snapshots: sqlstring: 2.3.3 optional: true + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + named-placeholders@1.1.6: dependencies: lru.min: 1.1.4 @@ -3758,6 +4997,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@3.3.18: {} + nanostores@1.1.1: {} napi-build-utils@2.0.0: {} @@ -3789,9 +5030,41 @@ snapshots: - '@babel/core' - babel-plugin-macros + next@16.3.0(@types/node@22.15.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@next/env': 16.3.0 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.11.13 + caniuse-lite: 1.0.30001716 + postcss: 8.5.23 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + styled-jsx: 5.1.6(react@19.1.0) + optionalDependencies: + '@next/swc-darwin-arm64': 16.3.0 + '@next/swc-darwin-x64': 16.3.0 + '@next/swc-linux-arm64-gnu': 16.3.0 + '@next/swc-linux-arm64-musl': 16.3.0 + '@next/swc-linux-x64-gnu': 16.3.0 + '@next/swc-linux-x64-musl': 16.3.0 + '@next/swc-win32-arm64-msvc': 16.3.0 + '@next/swc-win32-x64-msvc': 16.3.0 + sharp: 0.35.3(@types/node@22.15.2) + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - babel-plugin-macros + node-abi@3.87.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 + + node-emoji@2.2.0: + dependencies: + '@sindresorhus/is': 4.6.0 + char-regex: 1.0.2 + emojilib: 2.4.0 + skin-tone: 2.0.0 node-fetch-native@1.6.7: optional: true @@ -3800,13 +5073,17 @@ snapshots: dependencies: citty: 0.2.1 pathe: 2.0.3 - tinyexec: 1.0.2 + tinyexec: 1.3.0 optional: true + object-assign@4.1.1: {} + object-inspect@1.13.4: {} obug@2.1.1: {} + obug@3.0.0: {} + ohash@2.0.11: optional: true @@ -3818,6 +5095,16 @@ snapshots: dependencies: wrappy: 1.0.2 + package-manager-detector@1.8.0: {} + + parse5-htmlparser2-tree-adapter@6.0.1: + dependencies: + parse5: 6.0.1 + + parse5@5.1.1: {} + + parse5@6.0.1: {} + parseurl@1.3.3: {} path-key@3.1.1: @@ -3830,10 +5117,47 @@ snapshots: perfect-debounce@1.0.0: optional: true + pg-cloudflare@1.4.1: + optional: true + + pg-connection-string@2.14.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.23.1): + dependencies: + pg: 8.23.1 + + pg-protocol@1.16.1: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.23.1: + dependencies: + pg-connection-string: 2.14.1 + pg-pool: 3.14.0(pg@8.23.1) + pg-protocol: 1.16.1 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.1 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@4.0.3: {} + picomatch@4.0.7: {} + pkg-types@2.3.0: dependencies: confbox: 0.2.4 @@ -3847,12 +5171,22 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.6: + postcss@8.5.23: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + postgres@3.4.7: optional: true @@ -3901,6 +5235,13 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + publint@0.3.23: + dependencies: + '@publint/pack': 0.1.6 + package-manager-detector: 1.8.0 + picocolors: 1.1.1 + sade: 1.8.1 + pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -3956,12 +5297,16 @@ snapshots: readdirp@4.1.2: optional: true + redis-errors@1.2.0: {} + regexp-to-ast@0.5.0: optional: true remeda@2.33.4: optional: true + require-directory@2.1.1: {} + resolve-pkg-maps@1.0.0: {} retry@0.12.0: @@ -4036,9 +5381,9 @@ snapshots: rou3@0.7.12: {} - router@2.2.0: + router@2.2.0(supports-color@7.2.0): dependencies: - debug: 4.4.1 + debug: 4.4.3(supports-color@7.2.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -4046,6 +5391,10 @@ snapshots: transitivePeerDependencies: - supports-color + sade@1.8.1: + dependencies: + mri: 1.2.0 + safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} @@ -4054,11 +5403,11 @@ snapshots: semver@7.7.2: {} - semver@7.7.4: {} + semver@7.8.5: {} - send@1.2.0: + send@1.2.0(supports-color@7.2.0): dependencies: - debug: 4.4.1 + debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -4075,15 +5424,17 @@ snapshots: seq-queue@0.0.5: optional: true - serve-static@2.2.0: + serve-static@2.2.0(supports-color@7.2.0): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.0 + send: 1.2.0(supports-color@7.2.0) transitivePeerDependencies: - supports-color + server-only@0.0.1: {} + set-cookie-parser@2.7.2: {} set-cookie-parser@3.0.1: {} @@ -4094,7 +5445,7 @@ snapshots: dependencies: color: 4.2.3 detect-libc: 2.0.4 - semver: 7.7.4 + semver: 7.8.5 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.1 '@img/sharp-darwin-x64': 0.34.1 @@ -4118,6 +5469,40 @@ snapshots: '@img/sharp-win32-x64': 0.34.1 optional: true + sharp@0.35.3(@types/node@22.15.2): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 22.15.2 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -4170,11 +5555,11 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 - simple-git@3.27.0: + simple-git@3.27.0(supports-color@7.2.0): dependencies: - '@kwsites/file-exists': 1.1.1 + '@kwsites/file-exists': 1.1.1(supports-color@7.2.0) '@kwsites/promise-deferred': 1.1.1 - debug: 4.4.1 + debug: 4.4.1(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -4183,6 +5568,10 @@ snapshots: is-arrayish: 0.3.2 optional: true + skin-tone@2.0.0: + dependencies: + unicode-emoji-modifier-base: 1.0.0 + source-map-js@1.2.1: {} sparse-bitfield@3.0.3: @@ -4190,17 +5579,29 @@ snapshots: memory-pager: 1.5.0 optional: true + split2@4.2.0: {} + sqlstring@2.3.3: optional: true stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + statuses@2.0.1: {} std-env@3.10.0: {} + std-env@4.3.0: {} + streamsearch@1.1.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -4220,6 +5621,11 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + tar-fs@2.1.4: dependencies: chownr: 1.1.4 @@ -4235,17 +5641,38 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + tinybench@2.9.0: {} + tinybench@6.2.2: {} + tinyexec@1.0.2: {} + tinyexec@1.3.0: {} + + tinyexec@1.3.1: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + tinyrainbow@3.0.3: {} + tinyrainbow@3.2.0: {} + toidentifier@1.0.1: {} tr46@5.1.1: @@ -4255,7 +5682,7 @@ snapshots: tree-kill@1.2.2: {} - tsdown@0.20.3(typescript@5.9.2): + tsdown@0.20.3(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.2): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -4267,13 +5694,15 @@ snapshots: picomatch: 4.0.3 rolldown: 1.0.0-rc.3 rolldown-plugin-dts: 0.22.1(rolldown@1.0.0-rc.3)(typescript@5.9.2) - semver: 7.7.4 + semver: 7.8.5 tinyexec: 1.0.2 tinyglobby: 0.2.15 tree-kill: 1.2.2 unconfig-core: 7.4.2 unrun: 0.2.27 optionalDependencies: + '@arethetypeswrong/core': 0.18.5 + publint: 0.3.23 typescript: 5.9.2 transitivePeerDependencies: - '@ts-macro/tsc' @@ -4294,6 +5723,8 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.1 + typescript@5.6.1-rc: {} + typescript@5.9.2: {} unconfig-core@7.4.2: @@ -4303,6 +5734,8 @@ snapshots: undici-types@6.21.0: {} + unicode-emoji-modifier-base@1.0.0: {} + unpipe@1.0.0: {} unrun@0.2.27: @@ -4316,6 +5749,8 @@ snapshots: typescript: 5.9.2 optional: true + validate-npm-package-name@5.0.1: {} + vary@1.1.2: {} vite@7.3.1(@types/node@22.15.2)(jiti@2.6.1): @@ -4323,7 +5758,7 @@ snapshots: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - postcss: 8.5.6 + postcss: 8.5.23 rollup: 4.57.1 tinyglobby: 0.2.15 optionalDependencies: @@ -4368,6 +5803,28 @@ snapshots: - tsx - yaml + vitest@5.0.5(@types/node@22.15.2)(@vitest/coverage-v8@5.0.5)(vite@7.3.1(@types/node@22.15.2)(jiti@2.6.1)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.5(vite@7.3.1(@types/node@22.15.2)(jiti@2.6.1)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 3.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.2 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 7.3.1(@types/node@22.15.2)(jiti@2.6.1) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 22.15.2 + '@vitest/coverage-v8': 5.0.5(vitest@5.0.5) + transitivePeerDependencies: + - msw + webidl-conversions@7.0.0: optional: true @@ -4387,8 +5844,32 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + why-is-node-running@3.2.1: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yargs-parser@20.2.9: {} + + yargs@16.2.2: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + zeptomatch@2.1.0: dependencies: grammex: 3.1.12 @@ -4400,3 +5881,5 @@ snapshots: zod@3.25.51: {} zod@4.3.6: {} + + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 567c935..9aafd30 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,10 +1,16 @@ packages: - packages/* -onlyBuiltDependencies: - - '@biomejs/biome' - - core-js - - esbuild overrides: chargebee-init: link:packages/cli '@chargebee/express': link:packages/express '@chargebee/nextjs': link:packages/nextjs + '@chargebee/entitlements': link:packages/entitlements + '@chargebee/openfeature': link:packages/openfeature +allowBuilds: + '@biomejs/biome': true + '@prisma/engines': false + better-sqlite3: false + core-js: true + esbuild: true + prisma: false + sharp: false