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/entitlements/CHANGELOG.md b/packages/entitlements/CHANGELOG.md new file mode 100644 index 0000000..f5d7b81 --- /dev/null +++ b/packages/entitlements/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +## Unreleased + +- 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..e95055b --- /dev/null +++ b/packages/entitlements/README.md @@ -0,0 +1,362 @@ +# Chargebee Entitlements + +The `@chargebee/entitlements` makes it easy to work with entitlements and features in the Chargebee ecosystem. It handles fetching entitlements, caching them for frequent use either in Redis or in-memory, maintains a durable snapshot in a database for updates. It also supports background refresh, and an authenticated browser relay for Next.js 16 apps. It is a standalone package, but can be used with the [`@chargebee/openfeature`](https://github.com/chargebee/js-framework-adapters/blob/main/packages/openfeature/README.md) adapter. + +## Install + +```sh +pnpm add @chargebee/entitlements chargebee +``` + +## Usage + +Declare a feature once, then fetch its value wherever you need it: + +```ts +import { Feature } from "@chargebee/entitlements"; + +// Declare a typed feature +const seats = new Feature("licensed-seats", 0); + +// Fetch the value at runtime for the given context (customerId or subscriptionId) +const count = await seats.get({ customerId: user.chargebeeCustomerId }); + +``` + +A feature takes an ID and a default value. The default value is mandatory and is required for type cohesion during runtime to convert the chargebee feature value into a primitive. However, the type parameter is optional, since TypeScript infers it from the default: + +```ts +// features.ts +// export them individually +export const seats = new Feature("licensed-seats", 0); // Feature +export const tier = new Feature("support-tier", "basic"); // Feature +export const reports = new Feature("advanced-reports", false); // Feature + +// or as a grouped object +export default { + seats: new Feature("licensed-seats", 0), + tier: new Feature("support-tier", "basic"), + reports: new Feature("advanced-reports", false), +}; + +const count = await features.seats.get(target); +``` + +When you need the reason or Chargebee metadata behind a value, call +`getDetails` instead of `get`: + +```ts +const { value, reason, flagMetadata } = await seats.getDetails(target); +``` + +## Targets + +Every evaluation names one Chargebee subject: a customer, whose entitlements +are consolidated across their subscriptions, or a single subscription. + +```ts +await seats.get({ customerId: user.chargebeeCustomerId }); +await seats.get({ subscriptionId: subscription.id }); +``` + +There is nothing else to configure — no mode, no default scope. Passing both +identifiers is rejected rather than resolved, because a customer's +consolidated entitlements and one subscription's entitlements are different +answers and guessing between them would hide the mistake. Passing neither is +rejected too; in particular, a `targetingKey` is never assumed to be a +Chargebee ID. + +Any other properties on the object are ignored, so a request context you +already have on hand can be passed straight through: + +```ts +const count = await seats.get({ + targetingKey: session.user.id, + customerId: session.user.chargebeeCustomerId, +}); +``` + +## Server client + +Pass an initialized Chargebee client. Chargebee credentials remain entirely +in server code. + +```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, +}); +``` + +A standalone `new Feature(...)` needs a client to evaluate against. Register +one once during start-up: + +```ts +import { setDefaultEntitlements } from "@chargebee/entitlements"; +import { entitlements } from "@/lib/entitlements"; + +setDefaultEntitlements(entitlements); +``` + +To avoid the global, create features from the client instead — the returned +feature is bound to it: + +```ts +const seats = entitlements.feature("licensed-seats", 0); +``` + +For call sites that want the full resolution rather than a declared feature, +the client evaluates a feature ID directly: + +```ts +const resolution = await entitlements.getValue("licensed-seats", 0, { + customerId: session.user.chargebeeCustomerId, +}); +``` + +## Entitlement mapping + +The default value's type decides how a stored value 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 | Number | +| `number` | `unlimited` | `Number.POSITIVE_INFINITY` with `unlimited` metadata | +| object | Any | The full sanitized entitlement | + +Disabled or expired entitlements return the caller's default with the +`DISABLED` reason. Missing features return `FLAG_NOT_FOUND`; a value that +cannot be read as the declared type returns `TYPE_MISMATCH`. + +## Snapshot resolution + +The client fetches and caches the complete entitlement snapshot for a +customer or subscription. Evaluating multiple flags for the same target does +not make additional Chargebee calls. + +A snapshot is resolved in three steps: + +1. `cache` — a shared, fast store such as Redis or in-memory. It absorbs the many + entitlement checks a single authenticated request makes. +2. `durableStore` — a durable store such as PostgreSQL. It is the source of + truth. +3. The Chargebee API — used only when the store has nothing, then written back + to the store and the cache. + +Both slots take the same `EntitlementsStorage` interface, so any backend can +fill either role. With neither configured, every miss goes to Chargebee. + +```sh +pnpm add ioredis +``` + +```ts +import { createRedisEntitlementsCache } from "@chargebee/entitlements/cache"; +import Redis from "ioredis"; + +const redis = new Redis(process.env.REDIS_URL); +const cache = createRedisEntitlementsCache(redis, { ttlMs: 60_000 }); + +export const entitlements = new ChargebeeEntitlements({ + chargebeeClient: chargebee, + cache, + durableStore: postgresSnapshotStore, + snapshotTtlMs: 24 * 60 * 60_000, + advanced: { cacheNamespace: "my-app:chargebee:entitlements:v1" }, +}); +``` + +`createRedisEntitlementsCache` takes an [ioredis](https://github.com/redis/ioredis) +client (or anything with its `get`/`set`/`del` methods, such as a `Cluster`). +For another Redis client or a managed service like Upstash, implement the +three-method `EntitlementsStorage` interface directly instead: + +```ts +import type { EntitlementsStorage } from "@chargebee/entitlements/cache"; +import { + parseSerializedEntitlementsSnapshot, + serializeEntitlementsSnapshot, +} from "@chargebee/entitlements/cache"; + +const cache: EntitlementsStorage = { + get: async (key) => { + const value = await upstash.get(key); + return value ? parseSerializedEntitlementsSnapshot(value) : undefined; + }, + set: async (key, value, ttlMs = 60_000) => { + await upstash.set(key, serializeEntitlementsSnapshot(value), { px: ttlMs }); + }, + delete: async (key) => { + await upstash.del(key); + }, +}; +``` + +Cache expiry bounds how long the cache may lag the store: when it lapses, the +next evaluation reads the store again. Both bundled adapters take a `ttlMs` +option (60s by default), and the client's `cacheTtlMs` overrides it per write +if you would rather configure expiry alongside the client. Expiry matters most +for the cache created by `createMemoryEntitlementsCache`, which no other +process can evict — with several instances running, its `ttlMs` is the worst +case for how long one of them keeps serving entitlements a webhook has already +replaced. + +`snapshotTtlMs` stamps `expiresAt` on the snapshot and decides when it is +refreshed from Chargebee. A store should not delete rows at `expiresAt`: the +client serves an expired snapshot and refreshes it in the background, so +entitlements survive a Chargebee outage. Cache and store read failures degrade +to the next step and are reported through `onError`. Concurrent refreshes for +one target are deduplicated, and a failed refresh is not retried for +`advanced.refreshBackoffMs`. + +Refresh the snapshot after processing relevant Chargebee webhooks, and remove +it when the target no longer exists: + +```ts +await entitlements.refreshSnapshot({ subscriptionId }); +await entitlements.deleteSnapshot({ customerId }); +``` + +`refreshSnapshot` drops the cached copy before it calls Chargebee, so a webhook +that changed entitlements cannot be followed by a cache hit on the old values; +reads fall through to the store until the fresh snapshot lands. Use +`deleteSnapshot` to clear both layers. An explicit refresh also supersedes any +request refresh already in flight, because that fetch may have started before +the webhook's upstream change. `writeSnapshot` stores a snapshot you assembled +yourself, for example from a webhook payload, using `createEntitlementsSnapshot`. + +### Keeping Chargebee off the request path + +`refreshOnMiss: "background"` never calls Chargebee while a request waits. When +neither the cache nor the store holds a snapshot — a brand-new subscriber, for +example — the client starts the refresh, and evaluations resolve to the +caller's default value with reason `STALE` and `snapshotPending` metadata. The +application can render its free-tier experience and re-check once +`onSnapshotRefreshed` fires with `trigger: "request"`: + +```ts +const entitlements = new ChargebeeEntitlements({ + chargebeeClient: chargebee, + cache, + durableStore: postgresSnapshotStore, + refreshOnMiss: "background", + logger: console, + onSnapshotRefreshed: ({ target, trigger }) => { + if (trigger === "request") notifySubscriptionReady(target); + }, + onError: (error, { operation, target }) => { + logger.error({ error, operation, target }, "entitlement snapshot"); + }, +}); +``` + +Because the default values carry the decision while a snapshot is pending, pass +defaults that match your lowest paid-for tier rather than your most permissive +one. + +A background refresh continues after the response is sent, so on platforms that +freeze the process at that point, refresh snapshots from a webhook worker or a +reconciliation job instead of relying on request-triggered refreshes. + +## Browser client and relay + +The browser can't call Chargebee directly because the Chargebee API key is +secret. `ChargebeeEntitlementsWebClient` loads a sanitized snapshot from an +authenticated application endpoint and evaluates flags synchronously. + +Create an App Router route: + +```ts +// app/api/entitlements/route.ts +import { createEntitlementsRelayHandler } from "@chargebee/entitlements/nextjs"; +import { entitlements } from "@/lib/entitlements"; +import { getSession } from "@/lib/auth"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export const GET = createEntitlementsRelayHandler({ + entitlements, + resolveContext: async (request) => { + const session = await getSession(request); + if (!session?.user.chargebeeCustomerId) return null; + + return { customerId: session.user.chargebeeCustomerId }; + }, +}); +``` + +`resolveContext` must derive billing identity from the authenticated server +session. The relay rejects `customerId` and `subscriptionId` query parameters, +emits `private, no-store` responses, and never returns customer IDs, +subscription IDs, or credentials. `createEntitlementsRelayHandler` from +`@chargebee/entitlements/server` is the framework-neutral version, generic +over the request type; the `/nextjs` entry point instantiates it for +`NextRequest`. + +Because the relay snapshot is already scoped to the authenticated session, +features evaluated in the browser take no target at all: + +```ts +import { ChargebeeEntitlementsWebClient } from "@chargebee/entitlements/web"; +import { setDefaultEntitlements } from "@chargebee/entitlements"; + +const webClient = new ChargebeeEntitlementsWebClient({ + relayUrl: "/api/entitlements", +}); +await webClient.initialize(); +setDefaultEntitlements(webClient); + +// The same declarations used on the server, evaluated against the session +// snapshot already in memory: +const count = await features.seats.get(); +``` + +Or evaluate a feature ID directly, which the web client does synchronously: + +```ts +const resolution = webClient.getValue("advanced-reports", false); +``` + +The browser client fetches with `cache: "no-store"` and same-origin +credentials, stores the snapshot in memory, and performs synchronous +evaluations. Expired snapshots fail closed to caller defaults; pass `onStale`, +`onConfigurationChanged`, or `onError` callbacks to observe those transitions +(for example, to re-render once a stale snapshot has refreshed). Call `reset()` +when the session's billing subject changes (e.g. sign-in/sign-out) to clear the +snapshot and reload it, and `close()` during teardown. + +Browser billing identity always comes from `resolveContext` on the server, not +from the browser. For subscription-scoped browser access, have the +authenticated callback select and authorize the subscription from server +session state. Use separate relay URLs when a page needs independent +snapshots for multiple subscriptions. + +## Operational notes + +- Customer mode uses `consolidate_entitlements=true` by default. +- Chargebee pagination is followed with a page size of 100. +- Without a `durableStore`, an expired snapshot is refetched from Chargebee + before the evaluation resolves; stale grants are not served. +- Node.js is the supported Next.js runtime. Edge compatibility depends on the + application's Chargebee and authentication setup. +- Call `entitlements.close()` during long-lived process shutdown. + +## Using with OpenFeature + +If your application already standardizes on the +[OpenFeature](https://openfeature.dev) SDKs, wrap a `ChargebeeEntitlements` (or +`ChargebeeEntitlementsWebClient`) instance with +[`@chargebee/openfeature`](https://github.com/chargebee/js-framework-adapters/blob/main/packages/openfeature/README.md) +instead of calling this package's evaluation methods directly. Both approaches +share the same cache, store, and refresh behavior — `@chargebee/openfeature` +only translates method names and result shapes. diff --git a/packages/entitlements/package.json b/packages/entitlements/package.json new file mode 100644 index 0000000..4707ce1 --- /dev/null +++ b/packages/entitlements/package.json @@ -0,0 +1,122 @@ +{ + "name": "@chargebee/entitlements", + "author": "DX Chargebee", + "version": "0.1.0-alpha.1", + "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", + "README.md", + "LICENSE.md", + "CHANGELOG.md" + ], + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "typecheck": "tsc --project 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": { + "server-only": "^0.0.1" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", + "@types/node": "22.15.2", + "@vitest/coverage-v8": "^4.0.18", + "chargebee": "^3.30.0", + "ioredis": "^6.0.0", + "next": "^16.3.0", + "publint": "^0.3.23", + "tsdown": "^0.20.3", + "typescript": "^5.9.2", + "vitest": "^4.0.18" + }, + "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..4093259 --- /dev/null +++ b/packages/entitlements/src/index.ts @@ -0,0 +1,12 @@ +export type { + ChargebeeEntitlement, + ChargebeeTarget, + EntitlementErrorCode, + EntitlementResolution, + Logger, +} from "./shared"; +export { + type EntitlementsClient, + Feature, + setDefaultEntitlements, +} 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..7f37ecb --- /dev/null +++ b/packages/entitlements/src/server/entitlements.ts @@ -0,0 +1,454 @@ +import { createEntitlementsCacheKey, type EntitlementsStorage } from "../cache"; +import { + assertTarget, + type ChargebeeEntitlementsSnapshot, + type ChargebeeTarget, + createEntitlementsSnapshot, + type EntitlementResolution, + errorResolution, + Feature, + isSnapshotExpired, + type Logger, + resolveEntitlement, + type SnapshotSource, +} from "../shared"; +import { + type ChargebeeEntitlementsClient, + ChargebeeEntitlementsLoader, +} from "./loader"; + +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. Resolves a snapshot from + * a shared cache, a durable store, or the Chargebee API, and evaluates + * feature IDs against it. Use this directly, or wrap it with an adapter such + * as `@chargebee/openfeature`'s `ChargebeeEntitlementsProvider`. + */ +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. + */ + getValue( + featureId: string, + defaultValue: T, + target: ChargebeeTarget, + ): Promise> { + return this.evaluate(defaultValue, target, (result) => + resolveEntitlement( + result.snapshot, + featureId, + defaultValue, + result.source, + ), + ); + } + + /** + * 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); + } + + async getRelaySnapshot( + target: ChargebeeTarget, + ttlMs = 60_000, + ): Promise { + const { snapshot } = await this.getSnapshot(target); + return { + ...snapshot, + expiresAt: new Date( + Math.min(Date.parse(snapshot.expiresAt), Date.now() + ttlMs), + ).toISOString(), + }; + } + + private async evaluate( + defaultValue: T, + target: ChargebeeTarget, + resolve: (result: EntitlementsSnapshotResult) => EntitlementResolution, + ): Promise> { + try { + return resolve(await this.getSnapshot(target)); + } catch (error) { + if (error instanceof SnapshotPendingError) { + return { + value: defaultValue, + reason: "STALE", + flagMetadata: { snapshotPending: true }, + }; + } + return errorResolution( + defaultValue, + error instanceof InvalidEntitlementContextError + ? "INVALID_CONTEXT" + : "GENERAL", + 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. + }); + } + + 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..1df988d --- /dev/null +++ b/packages/entitlements/src/server/index.ts @@ -0,0 +1,12 @@ +export { createEntitlementsSnapshot } from "../shared"; +export { + ChargebeeEntitlements, + type ChargebeeEntitlementsOptions, + type SnapshotErrorInfo, + SnapshotPendingError, + type SnapshotRefreshedEvent, +} from "./entitlements"; +export { + type CreateEntitlementsRelayHandlerOptions, + createEntitlementsRelayHandler, +} from "./relay"; 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/shared/evaluation.ts b/packages/entitlements/src/shared/evaluation.ts new file mode 100644 index 0000000..8fe9b81 --- /dev/null +++ b/packages/entitlements/src/shared/evaluation.ts @@ -0,0 +1,212 @@ +import type { + ChargebeeEntitlement, + ChargebeeEntitlementsSnapshot, + EntitlementErrorCode, + EntitlementResolution, + SnapshotSource, +} from "./types"; + +type EnabledEntitlement = { + entitlement: ChargebeeEntitlement; + metadata: Record; +}; + +/** The value shapes a Chargebee entitlement can be read as. */ +type ValueKind = "boolean" | "string" | "number" | "object"; + +const BOOLEAN_VALUES = new Set(["true", "false", "available"]); + +/** + * 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"; +} + +function metadataFor( + entitlement: ChargebeeEntitlement, + source: SnapshotSource, +): Record { + return { + chargebeeFeatureId: entitlement.featureId, + chargebeeEnabled: entitlement.isEnabled, + cacheSource: source, + ...(entitlement.value !== undefined + ? { chargebeeValue: entitlement.value } + : {}), + ...(entitlement.featureType !== undefined + ? { chargebeeFeatureType: entitlement.featureType } + : {}), + ...(entitlement.featureUnit !== undefined + ? { chargebeeFeatureUnit: entitlement.featureUnit } + : {}), + ...(entitlement.isOverridden !== undefined + ? { chargebeeOverridden: entitlement.isOverridden } + : {}), + ...(entitlement.expiresAt !== undefined + ? { chargebeeExpiresAt: entitlement.expiresAt } + : {}), + }; +} + +export function errorResolution( + value: T, + errorCode: EntitlementErrorCode, + errorMessage: string, +): EntitlementResolution { + return { value, reason: "ERROR", errorCode, errorMessage }; +} + +function getEnabled( + snapshot: ChargebeeEntitlementsSnapshot, + featureId: string, + defaultValue: T, + source: SnapshotSource, +): EnabledEntitlement | EntitlementResolution { + const entitlement = snapshot.entitlements[featureId]; + if (!entitlement) { + return errorResolution( + defaultValue, + "FLAG_NOT_FOUND", + `Chargebee feature ${featureId} was not found`, + ); + } + + const metadata = metadataFor(entitlement, source); + const expired = + entitlement.expiresAt !== undefined && + entitlement.expiresAt * 1000 <= Date.now(); + if (!entitlement.isEnabled || expired) { + return { + value: defaultValue, + variant: "disabled", + reason: "DISABLED", + flagMetadata: metadata, + }; + } + + return { entitlement, metadata }; +} + +const reasonFor = (source: SnapshotSource) => + source === "api" ? "TARGETING_MATCH" : "CACHED"; + +interface ParsedEntitlementValue { + value: unknown; + variant?: string; + extraMetadata?: Record; + error?: { code: EntitlementErrorCode; message: string }; +} + +function parseEntitlementValue( + entitlement: ChargebeeEntitlement, + kind: ValueKind, + featureId: string, +): ParsedEntitlementValue { + switch (kind) { + case "boolean": { + const normalized = entitlement.value?.trim().toLowerCase(); + if ( + !normalized && + (entitlement.featureType === undefined || + entitlement.featureType === "switch") + ) { + return { value: true, variant: "enabled" }; + } + if (!BOOLEAN_VALUES.has(normalized ?? "")) { + return { + value: undefined, + error: { + code: "TYPE_MISMATCH", + message: `Chargebee feature ${featureId} is not a boolean entitlement`, + }, + }; + } + const value = normalized === "true" || normalized === "available"; + return { value, variant: value ? "enabled" : "disabled" }; + } + case "string": { + const value = entitlement.value; + if (value === undefined) { + return { + value: undefined, + error: { + code: "PARSE_ERROR", + message: `Chargebee feature ${featureId} has no value`, + }, + }; + } + return { value, variant: value }; + } + case "number": { + const rawValue = entitlement.value?.trim(); + if (rawValue?.toLowerCase() === "unlimited") { + return { + value: Number.POSITIVE_INFINITY, + variant: "unlimited", + extraMetadata: { unlimited: true }, + }; + } + const value = rawValue ? Number(rawValue) : Number.NaN; + if (!Number.isFinite(value)) { + return { + value: undefined, + error: { + code: "TYPE_MISMATCH", + message: `Chargebee feature ${featureId} is not a numeric entitlement`, + }, + }; + } + return { value, variant: rawValue }; + } + case "object": + return { + value: entitlement, + variant: entitlement.value ?? "enabled", + }; + } +} + +/** + * 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, +): EntitlementResolution { + const found = getEnabled(snapshot, featureId, defaultValue, source); + if (!("entitlement" in found)) return found; + + const parsed = parseEntitlementValue( + found.entitlement, + kindOf(defaultValue), + featureId, + ); + if (parsed.error) { + return errorResolution( + defaultValue, + parsed.error.code, + parsed.error.message, + ); + } + + return { + value: parsed.value as T, + variant: parsed.variant, + reason: reasonFor(source), + flagMetadata: parsed.extraMetadata + ? { ...found.metadata, ...parsed.extraMetadata } + : found.metadata, + }; +} diff --git a/packages/entitlements/src/shared/feature.ts b/packages/entitlements/src/shared/feature.ts new file mode 100644 index 0000000..28f18b2 --- /dev/null +++ b/packages/entitlements/src/shared/feature.ts @@ -0,0 +1,81 @@ +import type { ChargebeeTarget, EntitlementResolution } from "./types"; + +/** + * The one method a {@link Feature} needs from a client. Both + * `ChargebeeEntitlements` (server) and `ChargebeeEntitlementsWebClient` (web) + * satisfy it directly. + */ +export interface EntitlementsClient { + getValue( + featureId: string, + defaultValue: T, + target?: ChargebeeTarget, + ): Promise> | EntitlementResolution; +} + +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. + */ +export class Feature { + constructor( + readonly featureId: string, + readonly defaultValue: T, + private readonly client?: EntitlementsClient, + ) { + 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, `target` is optional because the + * relay snapshot is already scoped to the session. + */ + async get(target?: ChargebeeTarget): Promise { + return (await this.getDetails(target)).value; + } + + /** Like {@link get}, but returns the full resolution, not just the value. */ + async getDetails( + target?: ChargebeeTarget, + ): Promise> { + const client = 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.getValue(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..e75eef6 --- /dev/null +++ b/packages/entitlements/src/shared/index.ts @@ -0,0 +1,23 @@ +export { errorResolution, resolveEntitlement } from "./evaluation"; +export { + type EntitlementsClient, + Feature, + setDefaultEntitlements, +} from "./feature"; +export { + createEntitlementsSnapshot, + isSnapshotExpired, + parseEntitlementsSnapshot, + parseSerializedEntitlementsSnapshot, + serializeEntitlementsSnapshot, +} from "./snapshot"; +export { assertTarget } from "./target"; +export type { + ChargebeeEntitlement, + ChargebeeEntitlementsSnapshot, + ChargebeeTarget, + EntitlementErrorCode, + EntitlementResolution, + 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..10ad54e --- /dev/null +++ b/packages/entitlements/src/shared/target.ts @@ -0,0 +1,28 @@ +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): 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..b7e850a --- /dev/null +++ b/packages/entitlements/src/shared/types.ts @@ -0,0 +1,64 @@ +/** + * 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` and with the `Logger` type OpenFeature SDKs pass to providers. + */ +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"; + +export type EntitlementErrorCode = + | "PROVIDER_NOT_READY" + | "FLAG_NOT_FOUND" + | "PARSE_ERROR" + | "TYPE_MISMATCH" + | "TARGETING_KEY_MISSING" + | "INVALID_CONTEXT" + | "PROVIDER_FATAL" + | "GENERAL"; + +export interface EntitlementResolution { + value: T; + variant?: string; + reason?: string; + errorCode?: EntitlementErrorCode; + errorMessage?: string; + flagMetadata?: Record; +} diff --git a/packages/entitlements/src/web/client.ts b/packages/entitlements/src/web/client.ts new file mode 100644 index 0000000..09029ad --- /dev/null +++ b/packages/entitlements/src/web/client.ts @@ -0,0 +1,185 @@ +import { + type ChargebeeEntitlementsSnapshot, + type EntitlementResolution, + errorResolution, + Feature, + isSnapshotExpired, + parseEntitlementsSnapshot, + resolveEntitlement, +} from "../shared"; + +export interface ChargebeeEntitlementsWebClientOptions { + relayUrl: string | URL; + fetchImplementation?: typeof fetch; + credentials?: RequestCredentials; + requestHeaders?: HeadersInit; + /** The current snapshot has expired and a refresh has started. */ + onStale?: () => void; + /** A refresh completed with entitlements that differ from the previous snapshot. */ + onConfigurationChanged?: (flagsChanged: string[]) => void; + onError?: (message: string) => void; +} + +const changedFlags = ( + before: ChargebeeEntitlementsSnapshot | undefined, + after: ChargebeeEntitlementsSnapshot, +) => + [ + ...new Set([ + ...Object.keys(before?.entitlements ?? {}), + ...Object.keys(after.entitlements), + ]), + ].filter( + (flag) => + JSON.stringify(before?.entitlements[flag]) !== + JSON.stringify(after.entitlements[flag]), + ); + +/** + * 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. Use this directly, or wrap + * it with an adapter such as `@chargebee/openfeature`'s + * `ChargebeeEntitlementsWebProvider`. + */ +export class ChargebeeEntitlementsWebClient { + private readonly relayUrl: string; + private readonly fetchImplementation: typeof fetch; + private readonly credentials: RequestCredentials; + private readonly requestHeaders?: HeadersInit; + private readonly onStale?: () => void; + private readonly onConfigurationChanged?: (flagsChanged: string[]) => void; + private readonly onError?: (message: string) => void; + private snapshot?: ChargebeeEntitlementsSnapshot; + private staleEventEmitted = false; + private closed = false; + private refreshInProgress = false; + + constructor(options: ChargebeeEntitlementsWebClientOptions) { + 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.onStale = options.onStale; + this.onConfigurationChanged = options.onConfigurationChanged; + this.onError = options.onError; + } + + async initialize(): Promise { + this.closed = false; + await this.loadSnapshot(false); + } + + /** Clears the current snapshot and reloads it, e.g. after the session's billing subject changes. */ + async reset(): Promise { + this.snapshot = undefined; + this.staleEventEmitted = false; + this.refreshInProgress = false; + await this.refreshSnapshot(); + } + + async close(): Promise { + this.closed = true; + this.snapshot = undefined; + this.staleEventEmitted = false; + this.refreshInProgress = false; + } + + refreshSnapshot(): Promise { + return this.loadSnapshot(true); + } + + /** + * 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. + */ + getValue(featureId: string, defaultValue: T): EntitlementResolution { + const snapshot = this.getUsableSnapshot(defaultValue); + return "entitlements" in snapshot + ? resolveEntitlement(snapshot, featureId, defaultValue, "relay") + : snapshot; + } + + /** + * Declares a feature bound to this web client, evaluated synchronously + * against the current relay snapshot. + */ + feature(featureId: string, defaultValue: T): Feature { + return new Feature(featureId, defaultValue, this); + } + + private getUsableSnapshot( + defaultValue: T, + ): ChargebeeEntitlementsSnapshot | EntitlementResolution { + if (!this.snapshot) { + return errorResolution( + defaultValue, + "PROVIDER_NOT_READY", + "Chargebee entitlement snapshot is not loaded", + ); + } + + if (!isSnapshotExpired(this.snapshot)) return this.snapshot; + + if (!this.staleEventEmitted) { + this.onStale?.(); + this.staleEventEmitted = true; + } + + if (!this.refreshInProgress && !this.closed) { + this.refreshInProgress = true; + this.refreshSnapshot() + .catch(() => { + // Errors are already handled in loadSnapshot + }) + .finally(() => { + this.refreshInProgress = false; + }); + } + + return { value: defaultValue, reason: "STALE" }; + } + + private async loadSnapshot(emitChange: boolean): Promise { + if (this.closed) throw new Error("Chargebee web client is closed"); + + try { + const headers = new Headers(this.requestHeaders); + if (!headers.has("Accept")) headers.set("Accept", "application/json"); + const response = await this.fetchImplementation(this.relayUrl, { + method: "GET", + cache: "no-store", + credentials: this.credentials, + headers, + }); + if (!response.ok) { + throw new Error( + `Chargebee entitlement relay returned HTTP ${response.status}`, + ); + } + + const nextSnapshot = parseEntitlementsSnapshot(await response.json()); + const flagsChanged = changedFlags(this.snapshot, nextSnapshot); + this.snapshot = nextSnapshot; + this.staleEventEmitted = false; + if (emitChange && flagsChanged.length > 0) { + this.onConfigurationChanged?.(flagsChanged); + } + } catch (error) { + if (emitChange) { + this.onError?.( + error instanceof Error + ? error.message + : "Unable to refresh Chargebee entitlements", + ); + } + throw error; + } + } +} diff --git a/packages/entitlements/src/web/index.ts b/packages/entitlements/src/web/index.ts new file mode 100644 index 0000000..930c2f5 --- /dev/null +++ b/packages/entitlements/src/web/index.ts @@ -0,0 +1,4 @@ +export { + ChargebeeEntitlementsWebClient, + type ChargebeeEntitlementsWebClientOptions, +} 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..73a6c43 --- /dev/null +++ b/packages/entitlements/test/feature.test.ts @@ -0,0 +1,186 @@ +import type { CustomerEntitlement } from "chargebee"; +import { ChargebeeEntitlements } from "../src/server"; +import type { ChargebeeEntitlementsClient } from "../src/server/loader"; +import type { + ChargebeeTarget, + EntitlementResolution, + 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< + EntitlementResolution<{ featureId: string; value?: string }> + >(); + expect(details.value).toMatchObject({ + featureId: "support-tier", + value: "priority", + }); + }); + + it("exposes the full resolution via getDetails", async () => { + const seats = makeEntitlements().feature("licensed-seats", 0); + + const details = await seats.getDetails(target); + + expect(details).toMatchObject({ + value: 25, + reason: "TARGETING_MATCH", + flagMetadata: { chargebeeFeatureId: "licensed-seats" }, + }); + }); + + 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, + errorCode: "FLAG_NOT_FOUND", + }); + }); + + it("prefers a bound client over the default one", async () => { + const calls: string[] = []; + const stub = (name: string, value: number): EntitlementsClient => ({ + getValue: async () => { + calls.push(name); + return { value: value as never }; + }, + }); + 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("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.getValue", () => { + it("resolves every shape through a single method", async () => { + const entitlements = makeEntitlements(); + + await expect( + entitlements.getValue("licensed-seats", 0, target), + ).resolves.toMatchObject({ value: 25 }); + await expect( + entitlements.getValue("support-tier", "basic", target), + ).resolves.toMatchObject({ value: "priority" }); + await expect( + entitlements.getValue("advanced-reports", false, target), + ).resolves.toMatchObject({ value: true }); + await expect( + entitlements.getValue("support-tier", {}, target), + ).resolves.toMatchObject({ value: { featureId: "support-tier" } }); + }); +}); diff --git a/packages/entitlements/test/server.test.ts b/packages/entitlements/test/server.test.ts new file mode 100644 index 0000000..a250703 --- /dev/null +++ b/packages/entitlements/test/server.test.ts @@ -0,0 +1,570 @@ +import type { CustomerEntitlement } from "chargebee"; +import { + createMemoryEntitlementsCache, + type EntitlementsStorage, +} from "../src/cache"; +import { + ChargebeeEntitlements, + createEntitlementsRelayHandler, +} 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.getValue("sso", false, target); + const second = await entitlements.getValue("seats", 0, target); + + expect(first).toMatchObject({ value: true, reason: "TARGETING_MATCH" }); + expect(second).toMatchObject({ value: 10, reason: "CACHED" }); + 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_CONTEXT without calling Chargebee", async () => { + const { client, customerRequest } = makeClient([]); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: client, + }); + + await expect( + entitlements.getValue("sso", false, { + targetingKey: "app-user-1", + } as never), + ).resolves.toMatchObject({ + value: false, + errorCode: "INVALID_CONTEXT", + }); + await expect( + entitlements.getValue("sso", false, { + customerId: "customer-1", + subscriptionId: "subscription-1", + } as never), + ).resolves.toMatchObject({ + value: false, + errorCode: "INVALID_CONTEXT", + }); + 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.getValue("seats", 0, { subscriptionId: "subscription-1" }), + ).resolves.toMatchObject({ + value: 50, + flagMetadata: { + chargebeeFeatureType: "quantity", + chargebeeOverridden: 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 = { + targetingKey: "app-user-1", + customerId: "customer-1", + }; + + await expect( + entitlements.getValue("sso", false, context), + ).resolves.toMatchObject({ value: true, reason: "TARGETING_MATCH" }); + }); + + 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.getValue("sso", false, target), + ).resolves.toMatchObject({ + value: true, + reason: "CACHED", + flagMetadata: { cacheSource: "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.getValue("sso", false, target), + ).resolves.toMatchObject({ reason: "STALE" }); + 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.getValue("sso", false, target)).resolves.toEqual({ + value: false, + reason: "STALE", + flagMetadata: { snapshotPending: true }, + }); + + await vi.waitFor(() => + expect(onSnapshotRefreshed).toHaveBeenCalledWith( + expect.objectContaining({ trigger: "request" }), + ), + ); + await expect( + entitlements.getValue("sso", false, target), + ).resolves.toMatchObject({ value: true, reason: "CACHED" }); + 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.getValue("sso", false, target); + await vi.waitFor(() => + expect(onError).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ operation: "refresh" }), + ), + ); + await entitlements.getValue("sso", false, target); + + expect(customerRequest).toHaveBeenCalledTimes(1); + }); + + 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("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("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..961c569 --- /dev/null +++ b/packages/entitlements/test/shared.test.ts @@ -0,0 +1,203 @@ +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, + variant: "enabled", + reason: "TARGETING_MATCH", + }); + expect( + resolveEntitlement(snapshot, "switch-off", true, "cache"), + ).toMatchObject({ value: false, reason: "CACHED" }); + expect( + resolveEntitlement(snapshot, "switch-enabled", false, "api"), + ).toMatchObject({ value: true, variant: "enabled" }); + 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, + reason: "CACHED", + }); + expect(resolveEntitlement(snapshot, "storage", 0, "relay")).toMatchObject({ + value: Number.POSITIVE_INFINITY, + variant: "unlimited", + flagMetadata: { unlimited: true }, + }); + }); + + it("maps string and object entitlements", () => { + expect( + resolveEntitlement(snapshot, "support", "basic", "api"), + ).toMatchObject({ value: "priority", variant: "priority" }); + 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, reason: "DISABLED" }, + ); + expect(resolveEntitlement(snapshot, "expired", false, "api")).toMatchObject({ + value: false, + reason: "DISABLED", + }); + expect(resolveEntitlement(snapshot, "missing", false, "api")).toMatchObject({ + value: false, + errorCode: "FLAG_NOT_FOUND", + }); + expect(resolveEntitlement(snapshot, "support", false, "api")).toMatchObject({ + value: false, + errorCode: "TYPE_MISMATCH", + }); + expect(resolveEntitlement(snapshot, "support", 0, "api")).toMatchObject({ + value: 0, + errorCode: "TYPE_MISMATCH", + }); + expect( + resolveEntitlement(snapshot, "switch-enabled", "basic", "api"), + ).toMatchObject({ value: "basic", errorCode: "PARSE_ERROR" }); + }); +}); + +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 = { + targetingKey: "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({ targetingKey: "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..36480c2 --- /dev/null +++ b/packages/entitlements/test/web.test.ts @@ -0,0 +1,137 @@ +import { + createEntitlementsSnapshot, + Feature, + setDefaultEntitlements, +} from "../src/shared"; +import { ChargebeeEntitlementsWebClient } from "../src/web"; + +afterEach(() => { + setDefaultEntitlements(undefined); +}); + +describe("ChargebeeEntitlementsWebClient", () => { + it("loads a relay snapshot and evaluates synchronously", async () => { + const snapshot = createEntitlementsSnapshot( + [{ featureId: "sso", value: "true", isEnabled: true }], + 60_000, + ); + const fetchImplementation = vi.fn(async () => Response.json(snapshot)); + const client = new ChargebeeEntitlementsWebClient({ + relayUrl: "/api/entitlements", + fetchImplementation, + }); + + await client.initialize(); + + expect(client.getValue("sso", false)).toMatchObject({ value: true }); + expect(fetchImplementation).toHaveBeenCalledWith( + "/api/entitlements", + expect.objectContaining({ + cache: "no-store", + credentials: "same-origin", + }), + ); + }); + + it("fails closed when the relay snapshot expires", async () => { + const snapshot = createEntitlementsSnapshot( + [{ featureId: "sso", value: "true", isEnabled: true }], + 500, + Date.now() - 1_000, + ); + const onStale = vi.fn(); + const client = new ChargebeeEntitlementsWebClient({ + relayUrl: "/api/entitlements", + fetchImplementation: async () => Response.json(snapshot), + onStale, + }); + await client.initialize(); + + expect(client.getValue("sso", false)).toMatchObject({ + value: false, + reason: "STALE", + }); + expect(onStale).toHaveBeenCalledTimes(1); + }); + + it("refreshes its snapshot on reset", 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 onConfigurationChanged = vi.fn(); + const client = new ChargebeeEntitlementsWebClient({ + relayUrl: "/api/entitlements", + fetchImplementation, + onConfigurationChanged, + }); + + await client.initialize(); + expect(client.getValue("sso", true).value).toBe(false); + await client.reset(); + expect(client.getValue("sso", false).value).toBe(true); + expect(onConfigurationChanged).toHaveBeenCalledWith(["sso"]); + }); + + it("does not retain the previous subject's snapshot after a failed reset", 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 onError = vi.fn(); + const client = new ChargebeeEntitlementsWebClient({ + relayUrl: "/api/entitlements", + fetchImplementation, + onError, + }); + + await client.initialize(); + await expect(client.reset()).rejects.toThrow("HTTP 401"); + expect(onError).toHaveBeenCalledWith(expect.stringContaining("HTTP 401")); + expect(client.getValue("sso", false)).toMatchObject({ + value: false, + errorCode: "PROVIDER_NOT_READY", + }); + }); + + 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 ChargebeeEntitlementsWebClient({ + relayUrl: "/api/entitlements", + fetchImplementation: async () => Response.json(snapshot), + }); + await client.initialize(); + + // Bound client via webClient.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); + + // Global client via setDefaultEntitlements(webClient) + 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..e91c446 --- /dev/null +++ b/packages/entitlements/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + 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/packages/openfeature/CHANGELOG.md b/packages/openfeature/CHANGELOG.md new file mode 100644 index 0000000..f390956 --- /dev/null +++ b/packages/openfeature/CHANGELOG.md @@ -0,0 +1,91 @@ +# Changelog + +## Unreleased + +- 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..5e18a26 --- /dev/null +++ b/packages/openfeature/package.json @@ -0,0 +1,102 @@ +{ + "name": "@chargebee/openfeature", + "author": "DX Chargebee", + "version": "0.1.0-alpha.1", + "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..615d8aa --- /dev/null +++ b/packages/openfeature/src/index.ts @@ -0,0 +1,7 @@ +export type { + ChargebeeEntitlement, + ChargebeeTarget, + EntitlementErrorCode, + EntitlementResolution, + Logger, +} from "@chargebee/entitlements"; diff --git a/packages/openfeature/src/resolution.ts b/packages/openfeature/src/resolution.ts new file mode 100644 index 0000000..f44162c --- /dev/null +++ b/packages/openfeature/src/resolution.ts @@ -0,0 +1,17 @@ +import type { EntitlementResolution } from "@chargebee/entitlements"; + +/** + * Adapts an SDK-agnostic {@link EntitlementResolution} into the + * `ResolutionDetails` shape both OpenFeature SDKs expect. 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( + resolution: EntitlementResolution, +): Omit, "errorCode"> & { errorCode?: TErrorCode } { + return { + ...resolution, + errorCode: resolution.errorCode as unknown as TErrorCode | undefined, + }; +} 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..a5417df --- /dev/null +++ b/packages/openfeature/src/server/provider.ts @@ -0,0 +1,107 @@ +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 { 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_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 `getValue` which shape to parse the entitlement into. + */ + private async resolve( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + ): Promise> { + return toResolutionDetails( + await this.entitlements.getValue( + flagKey, + defaultValue, + targetFrom(context), + ), + ); + } +} 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..b649e12 --- /dev/null +++ b/packages/openfeature/src/web/provider.ts @@ -0,0 +1,106 @@ +import { + ChargebeeEntitlementsWebClient, + type ChargebeeEntitlementsWebClientOptions, +} from "@chargebee/entitlements/web"; +import { + type ErrorCode, + type EvaluationContext, + type JsonValue, + OpenFeatureEventEmitter, + type Provider, + ProviderEvents, + type ResolutionDetails, +} from "@openfeature/web-sdk"; +import { toResolutionDetails } from "../resolution"; + +export type ChargebeeEntitlementsWebProviderOptions = Omit< + ChargebeeEntitlementsWebClientOptions, + "onStale" | "onConfigurationChanged" | "onError" +>; + +/** + * Adapts `@chargebee/entitlements`'s framework-agnostic + * `ChargebeeEntitlementsWebClient` to the OpenFeature web `Provider` + * interface. All relay-fetch and evaluation logic lives in + * `ChargebeeEntitlementsWebClient` (exposed here as `.client`) — 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 client: ChargebeeEntitlementsWebClient; + + constructor(options: ChargebeeEntitlementsWebProviderOptions) { + this.client = new ChargebeeEntitlementsWebClient({ + ...options, + onStale: () => { + this.events.emit(ProviderEvents.Stale, { + message: "Chargebee entitlement snapshot expired", + }); + }, + onConfigurationChanged: (flagsChanged) => { + this.events.emit(ProviderEvents.ConfigurationChanged, { + flagsChanged, + }); + }, + onError: (message) => { + this.events.emit(ProviderEvents.Error, { message }); + }, + }); + } + + initialize(): Promise { + return this.client.initialize(); + } + + onContextChange( + _oldContext: EvaluationContext, + _newContext: EvaluationContext, + ): Promise { + return this.client.reset(); + } + + onClose(): Promise { + return this.client.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 `getValue` 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.client.getValue(flagKey, defaultValue), + ); + } +} diff --git a/packages/openfeature/test/server.test.ts b/packages/openfeature/test/server.test.ts new file mode 100644 index 0000000..a13757f --- /dev/null +++ b/packages/openfeature/test/server.test.ts @@ -0,0 +1,176 @@ +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("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..4f4bf91 --- /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.client, "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..2f0dae2 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: @@ -43,7 +45,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))(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@16.3.0(@types/node@22.15.2)(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)) better-call: specifier: ^1.1.8 version: 1.1.8(zod@4.3.6) @@ -55,7 +57,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 +81,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 +99,43 @@ 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 + '@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 + 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) + 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/express: dependencies: chargebee: @@ -111,7 +150,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 +165,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} @@ -230,24 +318,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 +353,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 +368,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 +389,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 +560,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==} @@ -587,6 +867,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 +883,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 +972,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 +998,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 +1071,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 +1113,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 +1196,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 +1300,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'} @@ -1042,14 +1423,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'} @@ -1068,6 +1464,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 +1615,18 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} 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 +1635,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 +1660,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 +1696,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 +1739,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 +1778,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 +1897,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 +1918,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'} @@ -1474,6 +1942,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==} @@ -1512,6 +1984,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 +2020,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 +2070,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 +2111,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 +2122,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 +2176,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'} @@ -1700,6 +2194,17 @@ packages: 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 +2273,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 +2284,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 +2296,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 +2334,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 +2371,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'} @@ -1846,6 +2392,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'} @@ -1878,8 +2436,8 @@ 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@3.4.7: @@ -1889,6 +2447,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 +2470,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 +2524,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 +2581,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 +2599,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 +2615,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 +2631,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 +2686,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'} @@ -2112,6 +2704,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'} @@ -2123,6 +2718,10 @@ packages: 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 +2750,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,6 +2761,13 @@ 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==} @@ -2165,6 +2775,10 @@ packages: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -2220,6 +2834,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 +2850,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 +2879,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'} @@ -2352,9 +2979,25 @@ packages: engines: {node: '>=8'} 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==} + 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 +3010,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 @@ -2484,6 +3153,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 +3174,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 +3196,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,84 +3294,193 @@ 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 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-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + '@img/sharp-linux-s390x@0.34.1': optionalDependencies: '@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 @@ -2707,14 +3495,18 @@ snapshots: '@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 +3521,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 +3683,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 +3809,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': {} @@ -3123,10 +3957,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: @@ -3146,7 +3992,9 @@ snapshots: 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))(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@16.3.0(@types/node@22.15.2)(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)): 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) @@ -3168,7 +4016,7 @@ snapshots: 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) + next: 16.3.0(@types/node@22.15.2)(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: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -3211,11 +4059,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 +4119,23 @@ snapshots: chai@6.2.2: {} + 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 +4161,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 +4206,8 @@ snapshots: color-string: 1.9.1 optional: true + commander@10.0.1: {} + confbox@0.2.4: optional: true @@ -3348,9 +4234,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 +4257,7 @@ snapshots: defu@6.1.4: {} - denque@2.1.0: - optional: true + denque@2.1.0: {} depd@2.0.0: {} @@ -3373,6 +4266,9 @@ snapshots: detect-libc@2.0.4: {} + detect-libc@2.1.2: + optional: true + dotenv@16.6.1: optional: true @@ -3403,6 +4299,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,6 +4316,8 @@ 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: {} @@ -3455,6 +4357,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 +4371,19 @@ snapshots: expect-type@1.3.0: {} - express@5.1.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 +4394,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 +4415,13 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + 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 +4452,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 +4510,8 @@ snapshots: dependencies: function-bind: 1.1.2 + highlight.js@10.7.3: {} + hono@4.11.4: optional: true @@ -3637,11 +4547,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 +4606,8 @@ snapshots: long@5.3.2: optional: true + lru-cache@11.5.2: {} + lru.min@1.1.4: optional: true @@ -3698,7 +4623,20 @@ snapshots: 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 +4674,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 +4691,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 +4704,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@3.3.18: {} + nanostores@1.1.1: {} napi-build-utils@2.0.0: {} @@ -3789,9 +4737,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,9 +4780,11 @@ 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: {} @@ -3818,6 +4800,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: @@ -3847,9 +4839,9 @@ 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 @@ -3901,6 +4893,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 +4955,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 +5039,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 +5049,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 +5061,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 +5082,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 +5103,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 +5127,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 +5213,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 +5226,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: @@ -4195,12 +5242,20 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + statuses@2.0.1: {} std-env@3.10.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 +5275,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,10 +5295,20 @@ 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: {} tinyexec@1.0.2: {} + tinyexec@1.3.0: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -4255,7 +5325,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 +5337,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 +5366,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 +5377,8 @@ snapshots: undici-types@6.21.0: {} + unicode-emoji-modifier-base@1.0.0: {} + unpipe@1.0.0: {} unrun@0.2.27: @@ -4316,6 +5392,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 +5401,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: @@ -4387,8 +5465,28 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + 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: {} + 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 +5498,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