From 2be591b160ef2c67962b340d4b113fba2eabd786 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Tue, 11 Aug 2026 14:33:33 +1000 Subject: [PATCH 01/22] add openfeature package --- packages/openfeature/CHANGELOG.md | 7 + packages/openfeature/LICENSE.md | 20 + packages/openfeature/README.md | 231 +++++ packages/openfeature/package.json | 129 +++ packages/openfeature/src/cache.ts | 236 +++++ packages/openfeature/src/index.ts | 17 + packages/openfeature/src/nextjs.ts | 51 ++ packages/openfeature/src/server.ts | 496 ++++++++++ packages/openfeature/src/shared.ts | 354 ++++++++ packages/openfeature/src/web.ts | 221 +++++ packages/openfeature/test/cache.test.ts | 98 ++ packages/openfeature/test/server.test.ts | 334 +++++++ packages/openfeature/test/shared.test.ts | 137 +++ packages/openfeature/test/web.test.ts | 116 +++ packages/openfeature/tsconfig.json | 24 + packages/openfeature/tsdown.config.ts | 22 + packages/openfeature/vitest.config.ts | 20 + pnpm-lock.yaml | 1057 +++++++++++++++++++++- pnpm-workspace.yaml | 1 + 19 files changed, 3548 insertions(+), 23 deletions(-) create mode 100644 packages/openfeature/CHANGELOG.md create mode 100644 packages/openfeature/LICENSE.md create mode 100644 packages/openfeature/README.md create mode 100644 packages/openfeature/package.json create mode 100644 packages/openfeature/src/cache.ts create mode 100644 packages/openfeature/src/index.ts create mode 100644 packages/openfeature/src/nextjs.ts create mode 100644 packages/openfeature/src/server.ts create mode 100644 packages/openfeature/src/shared.ts create mode 100644 packages/openfeature/src/web.ts create mode 100644 packages/openfeature/test/cache.test.ts create mode 100644 packages/openfeature/test/server.test.ts create mode 100644 packages/openfeature/test/shared.test.ts create mode 100644 packages/openfeature/test/web.test.ts create mode 100644 packages/openfeature/tsconfig.json create mode 100644 packages/openfeature/tsdown.config.ts create mode 100644 packages/openfeature/vitest.config.ts diff --git a/packages/openfeature/CHANGELOG.md b/packages/openfeature/CHANGELOG.md new file mode 100644 index 0000000..9264d45 --- /dev/null +++ b/packages/openfeature/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## 0.1.0 + +- Initial Chargebee Entitlements providers for the OpenFeature server and web SDKs. +- Secure framework-neutral and Next.js 16 entitlement relay helpers. +- Layered 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..25f8a2b --- /dev/null +++ b/packages/openfeature/README.md @@ -0,0 +1,231 @@ +# Chargebee OpenFeature provider + +`@chargebee/openfeature` exposes Chargebee Entitlements through the OpenFeature +server and web SDKs. It supports customer-level consolidated entitlements, +subscription-level entitlements, layered memory/Redis caching, and an +authenticated browser relay for Next.js 16. + +## Install + +Install only the OpenFeature SDKs used by your application: + +```sh +pnpm add @chargebee/openfeature 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. 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. `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, + chargebeeCustomerId: session.user.chargebeeCustomerId, + }, +); +``` + +For subscription-scoped evaluation: + +```ts +const seats = await OpenFeature.getClient().getNumberValue( + "licensed-seats", + 0, + { + targetingKey: session.user.id, + chargebeeEvaluationMode: "subscription", + chargebeeSubscriptionId: subscription.id, + }, +); +``` + +You can replace the built-in context keys with `resolveTarget(context)` when +your application has a different context model. + +## Entitlement mapping + +| Chargebee value | OpenFeature evaluation | +| --- | --- | +| Switch (`true`, `false`, or `available`) | Boolean | +| Custom level | String | +| Quantity or range | Number | +| `unlimited` | `Number.POSITIVE_INFINITY` with `unlimited` metadata | +| Full sanitized entitlement | Object | + +Disabled or expired entitlements return the caller's default with the +`DISABLED` reason. Missing features return `FLAG_NOT_FOUND`; invalid value +types return `TYPE_MISMATCH`. + +## Caching + +The provider 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. + +The default L1 cache is a bounded in-memory LRU with a 30-second TTL. Add a +shared Redis L2 cache for multi-instance or serverless deployments: + +```ts +import { + createRedisEntitlementsCache, + LayeredEntitlementsCache, +} from "@chargebee/openfeature/cache"; + +const redisCache = createRedisEntitlementsCache({ + get: (key) => redis.get(key), + set: async (key, value, ttlMs) => { + await redis.set(key, value, { PX: ttlMs }); + }, + delete: async (key) => { + await redis.del(key); + }, +}); + +const cache = new LayeredEntitlementsCache({ + redis: redisCache, + memoryTtlMs: 30_000, + redisTtlMs: 5 * 60_000, +}); + +export const entitlementsProvider = new ChargebeeEntitlementsProvider({ + chargebeeClient: chargebee, + cache, + cacheNamespace: "my-app:chargebee:entitlements:v1", +}); +``` + +The adapter is client-agnostic. Adapt other Redis clients as follows: + +```ts +// ioredis +set: async (key, value, ttlMs) => { + await ioRedis.set(key, value, "PX", ttlMs); +} + +// @upstash/redis +set: async (key, value, ttlMs) => { + await upstash.set(key, value, { px: ttlMs }); +} +``` + +Lookup order is memory, Redis, then Chargebee. Redis failures degrade to the +memory cache and Chargebee API. Concurrent misses in one process are +deduplicated. + +Invalidate both cache layers after processing relevant Chargebee webhooks: + +```ts +await entitlementsProvider.invalidate({ + mode: "customer", + customerId, +}); +``` + +Other application instances can retain an L1 value for at most the configured +short memory TTL after Redis invalidation. + +## 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. + +Create an App Router route: + +```ts +// app/api/entitlements/route.ts +import { createChargebeeEntitlementsHandler } from "@chargebee/openfeature/nextjs"; +import { entitlementsProvider } from "@/lib/entitlements"; +import { getSession } from "@/lib/auth"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export const GET = createChargebeeEntitlementsHandler({ + provider: entitlementsProvider, + resolveContext: async (request) => { + const session = await getSession(request); + if (!session?.user.chargebeeCustomerId) return null; + + return { + targetingKey: session.user.id, + chargebeeCustomerId: session.user.chargebeeCustomerId, + }; + }, +}); +``` + +`resolveContext` must derive billing identity from the authenticated server +session. The relay rejects Chargebee identity query parameters, emits +`private, no-store` responses, and never returns customer IDs, subscription +IDs, or credentials. + +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}; +} +``` + +The browser provider fetches with `cache: "no-store"` and same-origin +credentials, stores the snapshot in memory, refreshes it when OpenFeature +context changes, and performs synchronous evaluations. Expired snapshots fail +closed to caller defaults. + +Browser billing identity always comes from `resolveContext` on the server, not +from browser OpenFeature context. 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 + +- Customer mode uses `consolidate_entitlements=true` by default. +- Chargebee pagination is followed with a page size of 100. +- The default server cache fails closed after expiry; 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 `OpenFeature.close()` during long-lived process shutdown. diff --git a/packages/openfeature/package.json b/packages/openfeature/package.json new file mode 100644 index 0000000..dad1528 --- /dev/null +++ b/packages/openfeature/package.json @@ -0,0 +1,129 @@ +{ + "name": "@chargebee/openfeature", + "author": "DX Chargebee", + "version": "0.1.0", + "type": "module", + "description": "OpenFeature provider for Chargebee", + "license": "MIT", + "homepage": "https://github.com/chargebee/js-framework-adapters/blob/main/packages/openfeature/README.md", + "repository": { + "type": "git", + "url": "git@github.com:chargebee/js-framework-adapters.git", + "directory": "packages/openfeature" + }, + "keywords": [ + "chargebee", + "openfeature", + "entitlements", + "feature-flags", + "nextjs" + ], + "engines": { + "node": ">=20" + }, + "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.ts", + "types": "./dist/cache.d.mts", + "default": "./dist/cache.mjs" + }, + "./server": { + "dev-source": "./src/server.ts", + "types": "./dist/server.d.mts", + "default": "./dist/server.mjs" + }, + "./web": { + "dev-source": "./src/web.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", + "zod": "^4.4.3" + }, + "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", + "next": "^16.3.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", + "chargebee": "^3.30.0", + "next": "^16.0.0" + }, + "peerDependenciesMeta": { + "@openfeature/server-sdk": { + "optional": true + }, + "@openfeature/web-sdk": { + "optional": true + }, + "chargebee": { + "optional": true + }, + "next": { + "optional": true + } + } +} diff --git a/packages/openfeature/src/cache.ts b/packages/openfeature/src/cache.ts new file mode 100644 index 0000000..2471aa7 --- /dev/null +++ b/packages/openfeature/src/cache.ts @@ -0,0 +1,236 @@ +import { + type ChargebeeEntitlementsSnapshot, + type ChargebeeTarget, + isSnapshotExpired, + parseSerializedEntitlementsSnapshot, + serializeEntitlementsSnapshot, +} from "./shared"; + +export interface EntitlementsCache { + get(key: string): Promise; + set( + key: string, + value: ChargebeeEntitlementsSnapshot, + ttlMs: number, + ): Promise; + delete(key: string): Promise; + clear?(): Promise; +} + +export interface CacheLookup { + snapshot: ChargebeeEntitlementsSnapshot; + source: "memory" | "redis"; +} + +export interface MemoryEntitlementsCacheOptions { + maxEntries?: number; + now?: () => number; +} + +interface MemoryCacheEntry { + snapshot: ChargebeeEntitlementsSnapshot; + expiresAt: number; +} + +export class MemoryEntitlementsCache implements EntitlementsCache { + private readonly entries = new Map(); + private readonly maxEntries: number; + private readonly now: () => number; + + constructor(options: MemoryEntitlementsCacheOptions = {}) { + this.maxEntries = options.maxEntries ?? 500; + this.now = options.now ?? Date.now; + if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) { + throw new Error("maxEntries must be a positive integer"); + } + } + + async get(key: string): Promise { + const entry = this.entries.get(key); + if (!entry) return undefined; + + if ( + entry.expiresAt <= this.now() || + isSnapshotExpired(entry.snapshot, this.now()) + ) { + this.entries.delete(key); + return undefined; + } + + this.entries.delete(key); + this.entries.set(key, entry); + return entry.snapshot; + } + + async set( + key: string, + value: ChargebeeEntitlementsSnapshot, + ttlMs: number, + ): Promise { + if (ttlMs <= 0) return; + this.entries.delete(key); + this.entries.set(key, { + snapshot: value, + expiresAt: this.now() + ttlMs, + }); + + while (this.entries.size > this.maxEntries) { + const oldestKey = this.entries.keys().next().value; + if (oldestKey === undefined) break; + this.entries.delete(oldestKey); + } + } + + async delete(key: string): Promise { + this.entries.delete(key); + } + + async clear(): Promise { + this.entries.clear(); + } +} + +export interface RedisCacheCommands { + get(key: string): Promise; + set(key: string, value: string, ttlMs: number): Promise; + delete(key: string): Promise; +} + +export function createRedisEntitlementsCache( + commands: RedisCacheCommands, +): EntitlementsCache { + return { + async get(key) { + const value = await commands.get(key); + if (value == null) return undefined; + + try { + const snapshot = parseSerializedEntitlementsSnapshot(value); + if (isSnapshotExpired(snapshot)) { + await commands.delete(key); + return undefined; + } + return snapshot; + } catch { + await commands.delete(key); + return undefined; + } + }, + async set(key, value, ttlMs) { + await commands.set(key, serializeEntitlementsSnapshot(value), ttlMs); + }, + async delete(key) { + await commands.delete(key); + }, + }; +} + +export interface LayeredEntitlementsCacheOptions { + memory?: EntitlementsCache; + redis?: EntitlementsCache; + memoryTtlMs?: number; + redisTtlMs?: number; + onError?: (error: unknown, layer: "memory" | "redis") => void; +} + +export class LayeredEntitlementsCache { + private readonly memory: EntitlementsCache; + private readonly redis?: EntitlementsCache; + private readonly memoryTtlMs: number; + private readonly redisTtlMs: number; + private readonly onError?: ( + error: unknown, + layer: "memory" | "redis", + ) => void; + + constructor(options: LayeredEntitlementsCacheOptions = {}) { + this.memory = options.memory ?? new MemoryEntitlementsCache(); + this.redis = options.redis; + this.memoryTtlMs = options.memoryTtlMs ?? 30_000; + this.redisTtlMs = options.redisTtlMs ?? 300_000; + this.onError = options.onError; + } + + get snapshotTtlMs(): number { + return this.redis ? this.redisTtlMs : this.memoryTtlMs; + } + + async get(key: string): Promise { + try { + const memoryValue = await this.memory.get(key); + if (memoryValue) return { snapshot: memoryValue, source: "memory" }; + } catch (error) { + this.onError?.(error, "memory"); + } + + if (!this.redis) return undefined; + try { + const redisValue = await this.redis.get(key); + if (!redisValue) return undefined; + const remainingTtl = Date.parse(redisValue.expiresAt) - Date.now(); + await this.memory.set( + key, + redisValue, + Math.min(this.memoryTtlMs, remainingTtl), + ); + return { snapshot: redisValue, source: "redis" }; + } catch (error) { + this.onError?.(error, "redis"); + return undefined; + } + } + + async set( + key: string, + snapshot: ChargebeeEntitlementsSnapshot, + ): Promise { + if (this.redis) { + try { + await this.redis.set(key, snapshot, this.redisTtlMs); + } catch (error) { + this.onError?.(error, "redis"); + } + } + + try { + await this.memory.set(key, snapshot, this.memoryTtlMs); + } catch (error) { + this.onError?.(error, "memory"); + } + } + + async delete(key: string): Promise { + const deletions: Promise[] = [ + this.memory.delete(key).catch((error) => { + this.onError?.(error, "memory"); + }), + ]; + if (this.redis) { + deletions.push( + this.redis.delete(key).catch((error) => { + this.onError?.(error, "redis"); + }), + ); + } + await Promise.all(deletions); + } + + async clearMemory(): Promise { + await this.memory.clear?.(); + } +} + +export function createEntitlementsCacheKey( + target: ChargebeeTarget, + options: { + namespace?: string; + consolidateCustomerEntitlements?: boolean; + } = {}, +): string { + const namespace = options.namespace ?? "chargebee:openfeature:v1"; + const consolidated = options.consolidateCustomerEntitlements ?? true; + if (target.mode === "customer") { + return `${namespace}:customer:${encodeURIComponent(target.customerId)}:${consolidated ? "consolidated" : "individual"}`; + } + return `${namespace}:subscription:${encodeURIComponent(target.subscriptionId)}`; +} diff --git a/packages/openfeature/src/index.ts b/packages/openfeature/src/index.ts new file mode 100644 index 0000000..14c678e --- /dev/null +++ b/packages/openfeature/src/index.ts @@ -0,0 +1,17 @@ +export type { + ChargebeeEntitlement, + ChargebeeEntitlementsSnapshot, + ChargebeeEvaluationMode, + ChargebeeTarget, + EntitlementCacheSource, + EvaluationContextLike, +} from "./shared"; +export { + CHARGEBEE_CONTEXT_KEYS, + createEntitlementsSnapshot, + getTargetFromContext, + isSnapshotExpired, + parseEntitlementsSnapshot, + parseSerializedEntitlementsSnapshot, + serializeEntitlementsSnapshot, +} from "./shared"; diff --git a/packages/openfeature/src/nextjs.ts b/packages/openfeature/src/nextjs.ts new file mode 100644 index 0000000..4b499a8 --- /dev/null +++ b/packages/openfeature/src/nextjs.ts @@ -0,0 +1,51 @@ +import "server-only"; + +import type { EvaluationContext } from "@openfeature/server-sdk"; +import type { NextRequest } from "next/server"; +import { + type ChargebeeEntitlementsProvider, + createEntitlementsRelayHandler, +} from "./server"; + +export interface CreateChargebeeEntitlementsHandlerOptions { + provider: ChargebeeEntitlementsProvider; + resolveContext: ( + request: NextRequest, + ) => EvaluationContext | null | Promise; + snapshotTtlMs?: number; + onError?: ( + error: unknown, + request: NextRequest, + ) => Response | Promise; +} + +export type ChargebeeEntitlementsRouteHandler = ( + request: NextRequest, +) => Promise; + +export function createChargebeeEntitlementsHandler( + options: CreateChargebeeEntitlementsHandlerOptions, +): ChargebeeEntitlementsRouteHandler { + const onError = options.onError; + const handler = createEntitlementsRelayHandler({ + provider: options.provider, + resolveContext: (request) => options.resolveContext(request as NextRequest), + snapshotTtlMs: options.snapshotTtlMs, + onError: onError + ? (error, request) => onError(error, request as NextRequest) + : undefined, + }); + + return (request: NextRequest) => handler(request); +} + +export function createChargebeeEntitlementsRoute( + options: CreateChargebeeEntitlementsHandlerOptions, +): { GET: ChargebeeEntitlementsRouteHandler } { + return { GET: createChargebeeEntitlementsHandler(options) }; +} + +export type { + ChargebeeEntitlementsProvider, + ChargebeeEntitlementsProviderOptions, +} from "./server"; diff --git a/packages/openfeature/src/server.ts b/packages/openfeature/src/server.ts new file mode 100644 index 0000000..acd97eb --- /dev/null +++ b/packages/openfeature/src/server.ts @@ -0,0 +1,496 @@ +import type { + ErrorCode, + EvaluationContext, + JsonValue, + Logger, + Provider, + ResolutionDetails, +} from "@openfeature/server-sdk"; +import type Chargebee from "chargebee"; +import type { CustomerEntitlement, SubscriptionEntitlement } from "chargebee"; +import { + createEntitlementsCacheKey, + LayeredEntitlementsCache, + type LayeredEntitlementsCacheOptions, +} from "./cache"; +import { + CHARGEBEE_CONTEXT_KEYS, + type ChargebeeEntitlement, + type ChargebeeEntitlementsSnapshot, + type ChargebeeEvaluationMode, + type ChargebeeTarget, + createEntitlementsSnapshot, + type EntitlementResolution, + getTargetFromContext, + resolveBooleanEntitlement, + resolveNumberEntitlement, + resolveObjectEntitlement, + resolveStringEntitlement, +} from "./shared"; + +export interface ChargebeeEntitlementsClient + extends Pick {} + +export interface ChargebeeEntitlementsProviderOptions { + chargebeeClient: ChargebeeEntitlementsClient; + defaultMode?: ChargebeeEvaluationMode; + consolidateCustomerEntitlements?: boolean; + resolveTarget?: (context: EvaluationContext) => ChargebeeTarget; + cache?: LayeredEntitlementsCache | LayeredEntitlementsCacheOptions; + cacheNamespace?: string; + pageSize?: number; + maxPages?: number; +} + +export interface EntitlementsSnapshotResult { + snapshot: ChargebeeEntitlementsSnapshot; + source: "api" | "memory" | "redis"; +} + +interface InFlightSnapshotRequest { + promise: Promise; + state: { invalidated: boolean }; +} + +class InvalidEntitlementContextError extends Error {} + +export class ChargebeeEntitlementsProvider implements Provider { + readonly metadata = { name: "Chargebee Entitlements" } as const; + readonly runsOn = "server" as const; + + private readonly chargebeeClient: ChargebeeEntitlementsClient; + private readonly defaultMode: ChargebeeEvaluationMode; + private readonly consolidateCustomerEntitlements: boolean; + private readonly resolveTargetOption?: ( + context: EvaluationContext, + ) => ChargebeeTarget; + private readonly cache: LayeredEntitlementsCache; + private readonly cacheNamespace?: string; + private readonly pageSize: number; + private readonly maxPages: number; + private readonly inFlight = new Map(); + + constructor(options: ChargebeeEntitlementsProviderOptions) { + if (!options?.chargebeeClient) { + throw new Error("chargebeeClient is required"); + } + this.chargebeeClient = options.chargebeeClient; + this.defaultMode = options.defaultMode ?? "customer"; + this.consolidateCustomerEntitlements = + options.consolidateCustomerEntitlements ?? true; + this.resolveTargetOption = options.resolveTarget; + this.cache = + options.cache instanceof LayeredEntitlementsCache + ? options.cache + : new LayeredEntitlementsCache(options.cache); + this.cacheNamespace = options.cacheNamespace; + this.pageSize = options.pageSize ?? 100; + this.maxPages = options.maxPages ?? 50; + + if ( + !Number.isInteger(this.pageSize) || + this.pageSize < 1 || + this.pageSize > 100 + ) { + throw new Error("pageSize must be an integer between 1 and 100"); + } + if (!Number.isInteger(this.maxPages) || this.maxPages < 1) { + throw new Error("maxPages must be a positive integer"); + } + } + + async initialize(): Promise { + // The server provider is context-dynamic, so initialization only validates + // constructor configuration. Entitlements are loaded for each target lazily. + } + + async onClose(): Promise { + for (const request of this.inFlight.values()) { + request.state.invalidated = true; + } + this.inFlight.clear(); + await this.cache.clearMemory(); + } + + async resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + logger: Logger, + ): Promise> { + try { + const result = await this.getSnapshot(context, logger); + return this.toResolution( + resolveBooleanEntitlement( + result.snapshot, + flagKey, + defaultValue, + result.source, + ), + ); + } catch (error) { + return this.errorResolution(defaultValue, error); + } + } + + async resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context: EvaluationContext, + logger: Logger, + ): Promise> { + try { + const result = await this.getSnapshot(context, logger); + return this.toResolution( + resolveStringEntitlement( + result.snapshot, + flagKey, + defaultValue, + result.source, + ), + ); + } catch (error) { + return this.errorResolution(defaultValue, error); + } + } + + async resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context: EvaluationContext, + logger: Logger, + ): Promise> { + try { + const result = await this.getSnapshot(context, logger); + return this.toResolution( + resolveNumberEntitlement( + result.snapshot, + flagKey, + defaultValue, + result.source, + ), + ); + } catch (error) { + return this.errorResolution(defaultValue, error); + } + } + + async resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + logger: Logger, + ): Promise> { + try { + const result = await this.getSnapshot(context, logger); + return this.toResolution( + resolveObjectEntitlement( + result.snapshot, + flagKey, + defaultValue, + result.source, + ), + ); + } catch (error) { + return this.errorResolution(defaultValue, error); + } + } + + async getSnapshot( + context: EvaluationContext, + logger?: Logger, + ): Promise { + const target = this.resolveTarget(context); + const key = this.cacheKey(target); + const cached = await this.cache.get(key); + if (cached) return cached; + + const existingRequest = this.inFlight.get(key); + if (existingRequest) return existingRequest.promise; + + const state = { invalidated: false }; + const request = this.fetchSnapshot(target, key, state, logger).finally( + () => { + if (this.inFlight.get(key)?.promise === request) { + this.inFlight.delete(key); + } + }, + ); + this.inFlight.set(key, { promise: request, state }); + return request; + } + + async getRelaySnapshot( + context: EvaluationContext, + ttlMs = 60_000, + logger?: Logger, + ): Promise { + const { snapshot } = await this.getSnapshot(context, logger); + const relayExpiresAt = Math.min( + Date.parse(snapshot.expiresAt), + Date.now() + ttlMs, + ); + return { + ...snapshot, + expiresAt: new Date(relayExpiresAt).toISOString(), + }; + } + + async invalidate(target: ChargebeeTarget): Promise { + const key = this.cacheKey(target); + const inFlightRequest = this.inFlight.get(key); + if (inFlightRequest) inFlightRequest.state.invalidated = true; + this.inFlight.delete(key); + await this.cache.delete(key); + } + + async clearMemoryCache(): Promise { + await this.cache.clearMemory(); + } + + private resolveTarget(context: EvaluationContext): ChargebeeTarget { + try { + return this.resolveTargetOption + ? this.resolveTargetOption(context) + : getTargetFromContext(context, this.defaultMode); + } catch (error) { + throw new InvalidEntitlementContextError( + error instanceof Error ? error.message : "Invalid evaluation context", + ); + } + } + + private cacheKey(target: ChargebeeTarget): string { + return createEntitlementsCacheKey(target, { + namespace: this.cacheNamespace, + consolidateCustomerEntitlements: this.consolidateCustomerEntitlements, + }); + } + + private async fetchSnapshot( + target: ChargebeeTarget, + key: string, + state: { invalidated: boolean }, + logger?: Logger, + ): Promise { + const entitlements = + target.mode === "customer" + ? await this.fetchCustomerEntitlements(target.customerId, logger) + : await this.fetchSubscriptionEntitlements( + target.subscriptionId, + logger, + ); + const snapshot = createEntitlementsSnapshot( + target.mode, + entitlements, + this.cache.snapshotTtlMs, + ); + if (!state.invalidated) { + await this.cache.set(key, snapshot); + } + return { snapshot, source: "api" }; + } + + private async fetchCustomerEntitlements( + customerId: string, + logger?: Logger, + ): Promise { + const entitlements: ChargebeeEntitlement[] = []; + let offset: string | undefined; + + for (let page = 0; page < this.maxPages; page += 1) { + const response = + await this.chargebeeClient.customerEntitlement.entitlementsForCustomer( + customerId, + { + limit: this.pageSize, + offset, + consolidate_entitlements: this.consolidateCustomerEntitlements, + }, + ); + for (const item of response.list) { + const normalized = this.normalizeCustomerEntitlement( + item.customer_entitlement, + ); + if (normalized) entitlements.push(normalized); + else + logger?.warn("Chargebee customer entitlement omitted a feature_id"); + } + + offset = response.next_offset; + if (!offset) return entitlements; + } + + throw new Error( + `Chargebee customer entitlement pagination exceeded ${this.maxPages} pages`, + ); + } + + private async fetchSubscriptionEntitlements( + subscriptionId: string, + logger?: Logger, + ): Promise { + const entitlements: ChargebeeEntitlement[] = []; + let offset: string | undefined; + + for (let page = 0; page < this.maxPages; page += 1) { + const response = + await this.chargebeeClient.subscriptionEntitlement.subscriptionEntitlementsForSubscription( + subscriptionId, + { limit: this.pageSize, offset }, + ); + for (const item of response.list) { + const normalized = this.normalizeSubscriptionEntitlement( + item.subscription_entitlement, + ); + if (normalized) entitlements.push(normalized); + else + logger?.warn( + "Chargebee subscription entitlement omitted a feature_id", + ); + } + + offset = response.next_offset; + if (!offset) return entitlements; + } + + throw new Error( + `Chargebee subscription entitlement pagination exceeded ${this.maxPages} pages`, + ); + } + + private normalizeCustomerEntitlement( + entitlement: CustomerEntitlement, + ): ChargebeeEntitlement | undefined { + if (!entitlement.feature_id) return undefined; + return { + featureId: entitlement.feature_id, + isEnabled: entitlement.is_enabled, + ...(entitlement.value !== undefined ? { value: entitlement.value } : {}), + ...(entitlement.name !== undefined ? { name: entitlement.name } : {}), + }; + } + + private normalizeSubscriptionEntitlement( + entitlement: SubscriptionEntitlement, + ): ChargebeeEntitlement | undefined { + if (!entitlement.feature_id) return undefined; + return { + featureId: entitlement.feature_id, + isEnabled: entitlement.is_enabled, + isOverridden: entitlement.is_overridden, + ...(entitlement.value !== undefined ? { value: entitlement.value } : {}), + ...(entitlement.name !== undefined ? { name: entitlement.name } : {}), + ...(entitlement.feature_name !== undefined + ? { featureName: entitlement.feature_name } + : {}), + ...(entitlement.feature_unit !== undefined + ? { featureUnit: entitlement.feature_unit } + : {}), + ...(entitlement.feature_type !== undefined + ? { featureType: entitlement.feature_type } + : {}), + ...(entitlement.expires_at !== undefined + ? { expiresAt: entitlement.expires_at } + : {}), + }; + } + + private errorResolution( + defaultValue: T, + error: unknown, + ): ResolutionDetails { + const invalidContext = error instanceof InvalidEntitlementContextError; + return { + value: defaultValue, + reason: "ERROR", + errorCode: (invalidContext ? "INVALID_CONTEXT" : "GENERAL") as ErrorCode, + errorMessage: + error instanceof Error + ? error.message + : "Unable to evaluate Chargebee entitlement", + }; + } + + private toResolution( + resolution: EntitlementResolution, + ): ResolutionDetails { + return { + ...resolution, + errorCode: resolution.errorCode as ErrorCode | undefined, + }; + } +} + +export interface CreateEntitlementsRelayHandlerOptions { + provider: ChargebeeEntitlementsProvider; + resolveContext: ( + request: Request, + ) => EvaluationContext | null | Promise; + snapshotTtlMs?: number; + onError?: (error: unknown, request: Request) => Response | Promise; +} + +const PRIVATE_NO_STORE_HEADERS = { + "Cache-Control": "private, no-cache, no-store, max-age=0, must-revalidate", + "Content-Type": "application/json", +} as const; + +export function createEntitlementsRelayHandler( + options: CreateEntitlementsRelayHandlerOptions, +): (request: Request) => Promise { + return async (request: Request): Promise => { + try { + if (request.method !== "GET") { + return Response.json( + { error: "Method not allowed" }, + { + status: 405, + headers: { ...PRIVATE_NO_STORE_HEADERS, Allow: "GET" }, + }, + ); + } + + const url = new URL(request.url); + for (const key of Object.values(CHARGEBEE_CONTEXT_KEYS)) { + if (url.searchParams.has(key)) { + return Response.json( + { error: "Billing identity must be resolved by the server" }, + { status: 400, headers: PRIVATE_NO_STORE_HEADERS }, + ); + } + } + + const context = await options.resolveContext(request); + if (!context) { + return Response.json( + { error: "Unauthorized" }, + { status: 401, headers: PRIVATE_NO_STORE_HEADERS }, + ); + } + + const snapshot = await options.provider.getRelaySnapshot( + context, + options.snapshotTtlMs, + ); + return Response.json(snapshot, { + status: 200, + headers: PRIVATE_NO_STORE_HEADERS, + }); + } catch (error) { + if (options.onError) return options.onError(error, request); + return Response.json( + { error: "Unable to load entitlements" }, + { status: 502, headers: PRIVATE_NO_STORE_HEADERS }, + ); + } + }; +} + +export { CHARGEBEE_CONTEXT_KEYS }; +export type { + ChargebeeEntitlement, + ChargebeeEntitlementsSnapshot, + ChargebeeEvaluationMode, + ChargebeeTarget, +}; diff --git a/packages/openfeature/src/shared.ts b/packages/openfeature/src/shared.ts new file mode 100644 index 0000000..c42d45d --- /dev/null +++ b/packages/openfeature/src/shared.ts @@ -0,0 +1,354 @@ +import { z } from "zod"; + +export const CHARGEBEE_CONTEXT_KEYS = { + customerId: "chargebeeCustomerId", + subscriptionId: "chargebeeSubscriptionId", + evaluationMode: "chargebeeEvaluationMode", +} as const; + +export type ChargebeeEvaluationMode = "customer" | "subscription"; + +export type ChargebeeTarget = + | { mode: "customer"; customerId: string } + | { mode: "subscription"; subscriptionId: string }; + +export type EvaluationContextLike = Record & { + targetingKey?: string; +}; + +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; + targetMode: ChargebeeEvaluationMode; + entitlements: Record; +} + +export type EntitlementCacheSource = "api" | "memory" | "redis" | "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; +} + +const entitlementSchema = z.object({ + featureId: z.string().min(1), + value: z.string().optional(), + name: z.string().optional(), + featureName: z.string().optional(), + featureUnit: z.string().optional(), + featureType: z.string().optional(), + isEnabled: z.boolean(), + isOverridden: z.boolean().optional(), + expiresAt: z.number().int().optional(), +}); + +const snapshotSchema = z.object({ + schemaVersion: z.literal(1), + generatedAt: z.iso.datetime(), + expiresAt: z.iso.datetime(), + targetMode: z.enum(["customer", "subscription"]), + entitlements: z.record(z.string(), entitlementSchema), +}); + +export function parseEntitlementsSnapshot( + input: unknown, +): ChargebeeEntitlementsSnapshot { + return snapshotSchema.parse(input); +} + +export function serializeEntitlementsSnapshot( + snapshot: ChargebeeEntitlementsSnapshot, +): string { + return JSON.stringify(snapshot); +} + +export function parseSerializedEntitlementsSnapshot( + value: string, +): ChargebeeEntitlementsSnapshot { + return parseEntitlementsSnapshot(JSON.parse(value)); +} + +export function createEntitlementsSnapshot( + targetMode: ChargebeeEvaluationMode, + entitlements: Iterable, + ttlMs: number, + now = Date.now(), +): ChargebeeEntitlementsSnapshot { + const entries: Record = {}; + for (const entitlement of entitlements) { + entries[entitlement.featureId] = entitlement; + } + + return { + schemaVersion: 1, + generatedAt: new Date(now).toISOString(), + expiresAt: new Date(now + ttlMs).toISOString(), + targetMode, + entitlements: entries, + }; +} + +export function isSnapshotExpired( + snapshot: ChargebeeEntitlementsSnapshot, + now = Date.now(), +): boolean { + return Date.parse(snapshot.expiresAt) <= now; +} + +export function getTargetFromContext( + context: EvaluationContextLike, + defaultMode: ChargebeeEvaluationMode = "customer", +): ChargebeeTarget { + const explicitMode = context[CHARGEBEE_CONTEXT_KEYS.evaluationMode]; + const mode = + explicitMode === "customer" || explicitMode === "subscription" + ? explicitMode + : defaultMode; + + if (mode === "customer") { + const customerId = context[CHARGEBEE_CONTEXT_KEYS.customerId]; + if (typeof customerId !== "string" || customerId.length === 0) { + throw new Error( + `Evaluation context requires a non-empty ${CHARGEBEE_CONTEXT_KEYS.customerId}`, + ); + } + return { mode, customerId }; + } + + const subscriptionId = context[CHARGEBEE_CONTEXT_KEYS.subscriptionId]; + if (typeof subscriptionId !== "string" || subscriptionId.length === 0) { + throw new Error( + `Evaluation context requires a non-empty ${CHARGEBEE_CONTEXT_KEYS.subscriptionId}`, + ); + } + return { mode, subscriptionId }; +} + +function metadataFor( + entitlement: ChargebeeEntitlement, + source: EntitlementCacheSource, +): Record { + const metadata: Record = { + chargebeeFeatureId: entitlement.featureId, + chargebeeEnabled: entitlement.isEnabled, + cacheSource: source, + }; + if (entitlement.value !== undefined) { + metadata.chargebeeValue = entitlement.value; + } + if (entitlement.featureType !== undefined) { + metadata.chargebeeFeatureType = entitlement.featureType; + } + if (entitlement.featureUnit !== undefined) { + metadata.chargebeeFeatureUnit = entitlement.featureUnit; + } + if (entitlement.isOverridden !== undefined) { + metadata.chargebeeOverridden = entitlement.isOverridden; + } + if (entitlement.expiresAt !== undefined) { + metadata.chargebeeExpiresAt = entitlement.expiresAt; + } + return metadata; +} + +function errorResolution( + defaultValue: T, + errorCode: EntitlementErrorCode, + errorMessage: string, +): EntitlementResolution { + return { + value: defaultValue, + reason: "ERROR", + errorCode, + errorMessage, + }; +} + +function findEnabledEntitlement( + snapshot: ChargebeeEntitlementsSnapshot, + flagKey: string, + defaultValue: T, + source: EntitlementCacheSource, + now = Date.now(), +): + | { + entitlement: ChargebeeEntitlement; + metadata: Record; + } + | EntitlementResolution { + const entitlement = snapshot.entitlements[flagKey]; + if (!entitlement) { + return errorResolution( + defaultValue, + "FLAG_NOT_FOUND", + `Chargebee feature ${flagKey} was not found`, + ); + } + + const metadata = metadataFor(entitlement, source); + const isExpired = + entitlement.expiresAt !== undefined && entitlement.expiresAt * 1000 <= now; + if (!entitlement.isEnabled || isExpired) { + return { + value: defaultValue, + variant: "disabled", + reason: "DISABLED", + flagMetadata: metadata, + }; + } + + return { entitlement, metadata }; +} + +function successReason(source: EntitlementCacheSource): string { + return source === "api" ? "TARGETING_MATCH" : "CACHED"; +} + +export function resolveBooleanEntitlement( + snapshot: ChargebeeEntitlementsSnapshot, + flagKey: string, + defaultValue: boolean, + source: EntitlementCacheSource, +): EntitlementResolution { + const found = findEnabledEntitlement(snapshot, flagKey, defaultValue, source); + if (!("entitlement" in found)) return found; + + const normalized = found.entitlement.value?.trim().toLowerCase(); + if ( + (normalized === undefined || normalized === "") && + (found.entitlement.featureType === undefined || + found.entitlement.featureType === "switch") + ) { + return { + value: true, + variant: "enabled", + reason: successReason(source), + flagMetadata: found.metadata, + }; + } + if ( + normalized !== "true" && + normalized !== "false" && + normalized !== "available" + ) { + return errorResolution( + defaultValue, + "TYPE_MISMATCH", + `Chargebee feature ${flagKey} is not a boolean entitlement`, + ); + } + + const value = normalized === "true" || normalized === "available"; + return { + value, + variant: value ? "enabled" : "disabled", + reason: successReason(source), + flagMetadata: found.metadata, + }; +} + +export function resolveStringEntitlement( + snapshot: ChargebeeEntitlementsSnapshot, + flagKey: string, + defaultValue: string, + source: EntitlementCacheSource, +): EntitlementResolution { + const found = findEnabledEntitlement(snapshot, flagKey, defaultValue, source); + if (!("entitlement" in found)) return found; + + if (found.entitlement.value === undefined) { + return errorResolution( + defaultValue, + "PARSE_ERROR", + `Chargebee feature ${flagKey} has no value`, + ); + } + + return { + value: found.entitlement.value, + variant: found.entitlement.value, + reason: successReason(source), + flagMetadata: found.metadata, + }; +} + +export function resolveNumberEntitlement( + snapshot: ChargebeeEntitlementsSnapshot, + flagKey: string, + defaultValue: number, + source: EntitlementCacheSource, +): EntitlementResolution { + const found = findEnabledEntitlement(snapshot, flagKey, defaultValue, source); + if (!("entitlement" in found)) return found; + + const rawValue = found.entitlement.value?.trim(); + if (rawValue?.toLowerCase() === "unlimited") { + return { + value: Number.POSITIVE_INFINITY, + variant: "unlimited", + reason: successReason(source), + flagMetadata: { ...found.metadata, unlimited: true }, + }; + } + + const value = + rawValue === undefined || rawValue === "" ? Number.NaN : Number(rawValue); + if (!Number.isFinite(value)) { + return errorResolution( + defaultValue, + "TYPE_MISMATCH", + `Chargebee feature ${flagKey} is not a numeric entitlement`, + ); + } + + return { + value, + variant: rawValue, + reason: successReason(source), + flagMetadata: found.metadata, + }; +} + +export function resolveObjectEntitlement( + snapshot: ChargebeeEntitlementsSnapshot, + flagKey: string, + defaultValue: T, + source: EntitlementCacheSource, +): EntitlementResolution { + const found = findEnabledEntitlement(snapshot, flagKey, defaultValue, source); + if (!("entitlement" in found)) return found; + + return { + value: found.entitlement as unknown as T, + variant: found.entitlement.value ?? "enabled", + reason: successReason(source), + flagMetadata: found.metadata, + }; +} diff --git a/packages/openfeature/src/web.ts b/packages/openfeature/src/web.ts new file mode 100644 index 0000000..8908bf0 --- /dev/null +++ b/packages/openfeature/src/web.ts @@ -0,0 +1,221 @@ +import { + type ErrorCode, + type EvaluationContext, + type JsonValue, + type Logger, + OpenFeatureEventEmitter, + type Provider, + ProviderEvents, + type ResolutionDetails, +} from "@openfeature/web-sdk"; +import { + type ChargebeeEntitlementsSnapshot, + type EntitlementResolution, + isSnapshotExpired, + parseEntitlementsSnapshot, + resolveBooleanEntitlement, + resolveNumberEntitlement, + resolveObjectEntitlement, + resolveStringEntitlement, +} from "./shared"; + +export interface ChargebeeEntitlementsWebProviderOptions { + relayUrl: string | URL; + fetchImplementation?: typeof fetch; + credentials?: RequestCredentials; + requestHeaders?: HeadersInit; +} + +export class ChargebeeEntitlementsWebProvider implements Provider { + readonly metadata = { name: "Chargebee Entitlements" } as const; + readonly runsOn = "client" as const; + readonly events = new OpenFeatureEventEmitter(); + + private readonly relayUrl: string; + private readonly fetchImplementation: typeof fetch; + private readonly credentials: RequestCredentials; + private readonly requestHeaders?: HeadersInit; + private snapshot?: ChargebeeEntitlementsSnapshot; + private staleEventEmitted = false; + private closed = false; + + constructor(options: ChargebeeEntitlementsWebProviderOptions) { + 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; + } + + async initialize(): Promise { + this.closed = false; + await this.loadSnapshot(false); + } + + async onContextChange( + _oldContext: EvaluationContext, + _newContext: EvaluationContext, + ): Promise { + // The relay identity may have changed with the application session. Never + // evaluate a new context against the previous subject's entitlements. + this.snapshot = undefined; + this.staleEventEmitted = false; + await this.refreshSnapshot(); + } + + async onClose(): Promise { + this.closed = true; + this.snapshot = undefined; + this.staleEventEmitted = false; + } + + async refreshSnapshot(): Promise { + await this.loadSnapshot(true); + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + _context: EvaluationContext, + _logger: Logger, + ): ResolutionDetails { + const snapshot = this.getUsableSnapshot(defaultValue); + if (!("entitlements" in snapshot)) return snapshot; + return this.toResolution( + resolveBooleanEntitlement(snapshot, flagKey, defaultValue, "relay"), + ); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + _context: EvaluationContext, + _logger: Logger, + ): ResolutionDetails { + const snapshot = this.getUsableSnapshot(defaultValue); + if (!("entitlements" in snapshot)) return snapshot; + return this.toResolution( + resolveStringEntitlement(snapshot, flagKey, defaultValue, "relay"), + ); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + _context: EvaluationContext, + _logger: Logger, + ): ResolutionDetails { + const snapshot = this.getUsableSnapshot(defaultValue); + if (!("entitlements" in snapshot)) return snapshot; + return this.toResolution( + resolveNumberEntitlement(snapshot, flagKey, defaultValue, "relay"), + ); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + _context: EvaluationContext, + _logger: Logger, + ): ResolutionDetails { + const snapshot = this.getUsableSnapshot(defaultValue); + if (!("entitlements" in snapshot)) return snapshot; + return this.toResolution( + resolveObjectEntitlement(snapshot, flagKey, defaultValue, "relay"), + ); + } + + private getUsableSnapshot( + defaultValue: T, + ): ChargebeeEntitlementsSnapshot | ResolutionDetails { + if (!this.snapshot) { + return { + value: defaultValue, + reason: "ERROR", + errorCode: "PROVIDER_NOT_READY" as ErrorCode, + errorMessage: "Chargebee entitlement snapshot is not loaded", + }; + } + + if (isSnapshotExpired(this.snapshot)) { + if (!this.staleEventEmitted) { + this.events.emit(ProviderEvents.Stale, { + message: "Chargebee entitlement snapshot expired", + }); + this.staleEventEmitted = true; + } + return { + value: defaultValue, + reason: "STALE", + }; + } + + return this.snapshot; + } + + private toResolution( + resolution: EntitlementResolution, + ): ResolutionDetails { + return { + ...resolution, + errorCode: resolution.errorCode as ErrorCode | undefined, + }; + } + + private async loadSnapshot(emitChange: boolean): Promise { + if (this.closed) throw new Error("Chargebee web provider 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 previousFlags = new Set( + this.snapshot ? Object.keys(this.snapshot.entitlements) : [], + ); + const nextFlags = new Set(Object.keys(nextSnapshot.entitlements)); + const flagsChanged = [ + ...new Set([...previousFlags, ...nextFlags]), + ].filter( + (flag) => + JSON.stringify(this.snapshot?.entitlements[flag]) !== + JSON.stringify(nextSnapshot.entitlements[flag]), + ); + + this.snapshot = nextSnapshot; + this.staleEventEmitted = false; + if (emitChange && flagsChanged.length > 0) { + this.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged }); + } + } catch (error) { + if (emitChange) { + this.events.emit(ProviderEvents.Error, { + message: + error instanceof Error + ? error.message + : "Unable to refresh Chargebee entitlements", + }); + } + throw error; + } + } +} + +export type { + ChargebeeEntitlement, + ChargebeeEntitlementsSnapshot, +} from "./shared"; diff --git a/packages/openfeature/test/cache.test.ts b/packages/openfeature/test/cache.test.ts new file mode 100644 index 0000000..03cd1b7 --- /dev/null +++ b/packages/openfeature/test/cache.test.ts @@ -0,0 +1,98 @@ +import { + createRedisEntitlementsCache, + LayeredEntitlementsCache, + MemoryEntitlementsCache, +} from "../src/cache"; +import { createEntitlementsSnapshot } from "../src/shared"; + +function makeSnapshot(ttlMs = 60_000) { + return createEntitlementsSnapshot( + "customer", + [{ featureId: "sso", value: "true", isEnabled: true }], + ttlMs, + ); +} + +describe("MemoryEntitlementsCache", () => { + it("expires entries and evicts the least recently used entry", async () => { + let now = 1_000; + const cache = new MemoryEntitlementsCache({ + 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(); + }); +}); + +describe("Redis entitlement cache", () => { + it("serializes snapshots and removes corrupt values", async () => { + const values = new Map(); + const commands = { + get: vi.fn(async (key: string) => values.get(key)), + set: vi.fn(async (key: string, value: string) => { + values.set(key, value); + }), + delete: vi.fn(async (key: string) => { + values.delete(key); + }), + }; + const cache = createRedisEntitlementsCache(commands); + 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(commands.delete).toHaveBeenCalledWith("invalid"); + }); +}); + +describe("LayeredEntitlementsCache", () => { + it("hydrates memory from Redis and falls back when Redis fails", async () => { + const snapshot = makeSnapshot(); + const memory = new MemoryEntitlementsCache(); + const redis = { + get: vi.fn(async () => snapshot), + set: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + }; + const onError = vi.fn(); + const cache = new LayeredEntitlementsCache({ memory, redis, onError }); + + expect(await cache.get("target")).toMatchObject({ source: "redis", snapshot }); + expect(await cache.get("target")).toMatchObject({ source: "memory", snapshot }); + expect(redis.get).toHaveBeenCalledTimes(1); + + redis.get.mockRejectedValueOnce(new Error("redis unavailable")); + expect(await cache.get("another-target")).toBeUndefined(); + expect(onError).toHaveBeenCalledWith(expect.any(Error), "redis"); + }); + + it("invalidates both layers", async () => { + const snapshot = makeSnapshot(); + const memory = new MemoryEntitlementsCache(); + const redis = { + get: vi.fn(async () => undefined), + set: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + }; + const cache = new LayeredEntitlementsCache({ memory, redis }); + + await cache.set("target", snapshot); + await cache.delete("target"); + + expect(await memory.get("target")).toBeUndefined(); + expect(redis.delete).toHaveBeenCalledWith("target"); + }); +}); diff --git a/packages/openfeature/test/server.test.ts b/packages/openfeature/test/server.test.ts new file mode 100644 index 0000000..36780a5 --- /dev/null +++ b/packages/openfeature/test/server.test.ts @@ -0,0 +1,334 @@ +import { + type EvaluationContext, + OpenFeature, +} from "@openfeature/server-sdk"; +import type { CustomerEntitlement } from "chargebee"; +import { MemoryEntitlementsCache } from "../src/cache"; +import { + type ChargebeeEntitlementsClient, + ChargebeeEntitlementsProvider, + createEntitlementsRelayHandler, +} from "../src/server"; + +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 context: EvaluationContext = { + targetingKey: "app-user-1", + chargebeeCustomerId: "customer-1", +}; + +afterEach(async () => { + await OpenFeature.clearProviders(); +}); + +describe("ChargebeeEntitlementsProvider", () => { + 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 provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: client, + cache: { memory: new MemoryEntitlementsCache(), memoryTtlMs: 60_000 }, + }); + + const first = await provider.resolveBooleanEvaluation( + "sso", + false, + context, + console, + ); + const second = await provider.resolveNumberEvaluation( + "seats", + 0, + context, + console, + ); + + 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 provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: { + customerEntitlement: { + entitlementsForCustomer: customerRequest, + }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: vi.fn(), + }, + } as unknown as ChargebeeEntitlementsClient, + }); + + const first = provider.getSnapshot(context); + const second = provider.getSnapshot(context); + 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 provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: { + customerEntitlement: { + entitlementsForCustomer: customerRequest, + }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: vi.fn(), + }, + } as unknown as ChargebeeEntitlementsClient, + }); + + const first = provider.getSnapshot(context); + await started; + await provider.invalidate({ mode: "customer", customerId: "customer-1" }); + release?.(); + await first; + await provider.getSnapshot(context); + + expect(customerRequest).toHaveBeenCalledTimes(2); + }); + + it("returns 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" }, + console, + ), + ).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 provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: { + customerEntitlement: { + entitlementsForCustomer: vi.fn(), + }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: subscriptionRequest, + }, + } as unknown as ChargebeeEntitlementsClient, + }); + + await expect( + provider.resolveNumberEvaluation( + "seats", + 0, + { + chargebeeEvaluationMode: "subscription", + chargebeeSubscriptionId: "subscription-1", + }, + console, + ), + ).resolves.toMatchObject({ + value: 50, + flagMetadata: { + chargebeeFeatureType: "quantity", + chargebeeOverridden: true, + }, + }); + expect(subscriptionRequest).toHaveBeenCalledWith( + "subscription-1", + expect.objectContaining({ limit: 100 }), + ); + }); + + it("works through the OpenFeature server SDK", async () => { + const { client } = makeClient([ + { + list: [ + { + customer_entitlement: { + customer_id: "customer-1", + feature_id: "sso", + value: "true", + is_enabled: true, + }, + }, + ], + }, + ]); + await OpenFeature.setProviderAndWait( + new ChargebeeEntitlementsProvider({ chargebeeClient: client }), + ); + + await expect( + OpenFeature.getClient().getBooleanValue("sso", false, context), + ).resolves.toBe(true); + }); +}); + +describe("entitlement relay", () => { + it("derives identity on the server and rejects spoofed IDs", async () => { + const { client } = makeClient([{ list: [] }]); + const provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: client, + }); + const resolveContext = vi.fn(async () => context); + const handler = createEntitlementsRelayHandler({ + provider, + resolveContext, + }); + + const spoofed = await handler( + new Request( + "https://example.com/api/entitlements?chargebeeCustomerId=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({ + provider: new ChargebeeEntitlementsProvider({ + 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 () => context); + const handler = createEntitlementsRelayHandler({ + provider: new ChargebeeEntitlementsProvider({ + 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/openfeature/test/shared.test.ts b/packages/openfeature/test/shared.test.ts new file mode 100644 index 0000000..ef549c7 --- /dev/null +++ b/packages/openfeature/test/shared.test.ts @@ -0,0 +1,137 @@ +import { + createEntitlementsSnapshot, + getTargetFromContext, + resolveBooleanEntitlement, + resolveNumberEntitlement, + resolveObjectEntitlement, + resolveStringEntitlement, +} from "../src/shared"; + +const now = Date.UTC(2026, 0, 1); + +const snapshot = createEntitlementsSnapshot( + "customer", + [ + { featureId: "switch-on", value: "true", isEnabled: true }, + { featureId: "switch-off", value: "false", isEnabled: true }, + { featureId: "switch-enabled", 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( + resolveBooleanEntitlement(snapshot, "switch-on", false, "api"), + ).toMatchObject({ + value: true, + variant: "enabled", + reason: "TARGETING_MATCH", + }); + expect( + resolveBooleanEntitlement(snapshot, "switch-off", true, "memory"), + ).toMatchObject({ + value: false, + reason: "CACHED", + }); + expect( + resolveBooleanEntitlement(snapshot, "switch-enabled", false, "api"), + ).toMatchObject({ + value: true, + variant: "enabled", + }); + }); + + it("maps numeric and unlimited entitlements", () => { + expect(resolveNumberEntitlement(snapshot, "seats", 0, "redis")).toMatchObject( + { + value: 25, + reason: "CACHED", + }, + ); + expect( + resolveNumberEntitlement(snapshot, "storage", 0, "relay"), + ).toMatchObject({ + value: Number.POSITIVE_INFINITY, + variant: "unlimited", + flagMetadata: { unlimited: true }, + }); + }); + + it("maps string and object entitlements", () => { + expect( + resolveStringEntitlement(snapshot, "support", "basic", "api"), + ).toMatchObject({ + value: "priority", + variant: "priority", + }); + expect( + resolveObjectEntitlement(snapshot, "support", {}, "api").value, + ).toMatchObject({ + featureId: "support", + value: "priority", + }); + }); + + it("fails closed for disabled, expired, missing, and mismatched values", () => { + expect( + resolveBooleanEntitlement(snapshot, "disabled", false, "api"), + ).toMatchObject({ + value: false, + reason: "DISABLED", + }); + expect( + resolveBooleanEntitlement(snapshot, "expired", false, "api"), + ).toMatchObject({ + value: false, + reason: "DISABLED", + }); + expect( + resolveBooleanEntitlement(snapshot, "missing", false, "api"), + ).toMatchObject({ + value: false, + errorCode: "FLAG_NOT_FOUND", + }); + expect( + resolveBooleanEntitlement(snapshot, "support", false, "api"), + ).toMatchObject({ + value: false, + errorCode: "TYPE_MISMATCH", + }); + }); +}); + +describe("target context", () => { + it("resolves explicit customer and subscription targets", () => { + expect(getTargetFromContext({ chargebeeCustomerId: "customer-1" })).toEqual({ + mode: "customer", + customerId: "customer-1", + }); + expect( + getTargetFromContext({ + chargebeeEvaluationMode: "subscription", + chargebeeSubscriptionId: "subscription-1", + }), + ).toEqual({ + mode: "subscription", + subscriptionId: "subscription-1", + }); + }); + + it("does not treat targetingKey as a Chargebee identifier", () => { + expect(() => getTargetFromContext({ targetingKey: "app-user-1" })).toThrow( + "chargebeeCustomerId", + ); + }); +}); diff --git a/packages/openfeature/test/web.test.ts b/packages/openfeature/test/web.test.ts new file mode 100644 index 0000000..bc562b4 --- /dev/null +++ b/packages/openfeature/test/web.test.ts @@ -0,0 +1,116 @@ +import { OpenFeature } from "@openfeature/web-sdk"; +import { createEntitlementsSnapshot } from "../src/shared"; +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( + "customer", + [{ 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("fails closed when the relay snapshot expires", async () => { + const snapshot = createEntitlementsSnapshot( + "customer", + [{ featureId: "sso", value: "true", isEnabled: true }], + 500, + Date.now() - 1_000, + ); + const provider = new ChargebeeEntitlementsWebProvider({ + relayUrl: "/api/entitlements", + fetchImplementation: async () => Response.json(snapshot), + }); + await provider.initialize(); + + expect( + provider.resolveBooleanEvaluation("sso", false, {}, console), + ).toMatchObject({ + value: false, + reason: "STALE", + }); + }); + + it("refreshes its snapshot when context changes", async () => { + const first = createEntitlementsSnapshot( + "customer", + [{ featureId: "sso", value: "false", isEnabled: true }], + 60_000, + ); + const second = createEntitlementsSnapshot( + "customer", + [{ 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, + }); + + await provider.initialize(); + expect( + provider.resolveBooleanEvaluation("sso", true, {}, console).value, + ).toBe(false); + await provider.onContextChange({}, { targetingKey: "new-user" }); + expect( + provider.resolveBooleanEvaluation("sso", false, {}, console).value, + ).toBe(true); + }); + + it("does not retain the previous subject's snapshot after a failed context change", async () => { + const snapshot = createEntitlementsSnapshot( + "customer", + [{ 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, {}, console), + ).toMatchObject({ + value: false, + errorCode: "PROVIDER_NOT_READY", + }); + }); +}); diff --git a/packages/openfeature/tsconfig.json b/packages/openfeature/tsconfig.json new file mode 100644 index 0000000..b6e3391 --- /dev/null +++ b/packages/openfeature/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/openfeature/tsdown.config.ts b/packages/openfeature/tsdown.config.ts new file mode 100644 index 0000000..e0d53b3 --- /dev/null +++ b/packages/openfeature/tsdown.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + dts: { build: true, incremental: true }, + format: ["esm"], + entry: [ + "./src/index.ts", + "./src/cache.ts", + "./src/server.ts", + "./src/web.ts", + "./src/nextjs.ts", + ], + external: [ + "@openfeature/server-sdk", + "@openfeature/web-sdk", + "chargebee", + "next", + "next/server", + "server-only", + ], + sourcemap: true, +}); diff --git a/packages/openfeature/vitest.config.ts b/packages/openfeature/vitest.config.ts new file mode 100644 index 0000000..e91c446 --- /dev/null +++ b/packages/openfeature/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/pnpm-lock.yaml b/pnpm-lock.yaml index d64494b..ea605f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,8 +6,9 @@ settings: overrides: chargebee-init: link:packages/cli - '@chargebee/nextjs': link:packages/nextjs '@chargebee/express': link:packages/express + '@chargebee/nextjs': link:packages/nextjs + '@chargebee/openfeature': link:packages/openfeature importers: @@ -43,7 +44,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 +56,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 @@ -126,8 +127,63 @@ 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: + server-only: + specifier: ^0.0.1 + version: 0.0.1 + zod: + specifier: ^4.4.3 + version: 4.4.3 + 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 + 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: + '@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} @@ -261,6 +317,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 +332,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 +353,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 +524,291 @@ 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] + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -587,6 +828,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 +844,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 +933,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 +959,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 +1032,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 +1074,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 +1157,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 +1261,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 +1384,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 +1425,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 +1576,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 +1596,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 +1621,24 @@ 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==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1255,6 +1653,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==} @@ -1324,6 +1726,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 +1845,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 +1866,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 +1890,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 +1932,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 +1968,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 +2018,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'} @@ -1636,6 +2066,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 +2120,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 +2138,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 +2217,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 +2228,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 +2240,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 +2278,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 +2315,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 +2336,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 +2380,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 +2391,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 +2414,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==} @@ -1966,6 +2474,10 @@ packages: 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 +2521,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 +2539,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 +2555,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 +2571,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 +2626,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'} @@ -2123,6 +2655,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 +2687,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 +2698,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 +2712,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 +2771,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 +2787,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 +2816,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 +2916,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 +2947,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 +3090,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 +3111,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 +3133,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 +3231,191 @@ 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 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2715,6 +3438,10 @@ snapshots: '@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 +3456,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 +3618,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 +3744,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 +3892,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 +3927,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 +3951,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) @@ -3271,10 +4054,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 +4096,36 @@ 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 + 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 +4139,8 @@ snapshots: color-string: 1.9.1 optional: true + commander@10.0.1: {} + confbox@0.2.4: optional: true @@ -3373,6 +4192,9 @@ snapshots: detect-libc@2.0.4: {} + detect-libc@2.1.2: + optional: true + dotenv@16.6.1: optional: true @@ -3403,6 +4225,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 +4242,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 +4283,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: @@ -3511,6 +4341,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.3: {} + file-uri-to-path@1.0.0: {} finalhandler@2.1.0: @@ -3546,6 +4378,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 +4436,8 @@ snapshots: dependencies: function-bind: 1.1.2 + highlight.js@10.7.3: {} + hono@4.11.4: optional: true @@ -3642,6 +4478,8 @@ snapshots: 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 +4521,8 @@ snapshots: long@5.3.2: optional: true + lru-cache@11.5.2: {} + lru.min@1.1.4: optional: true @@ -3698,7 +4538,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 +4589,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 +4606,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 +4619,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@3.3.18: {} + nanostores@1.1.1: {} napi-build-utils@2.0.0: {} @@ -3789,9 +4652,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 +4695,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 +4715,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 +4754,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 +4808,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 @@ -3962,6 +4876,8 @@ snapshots: remeda@2.33.4: optional: true + require-directory@2.1.1: {} + resolve-pkg-maps@1.0.0: {} retry@0.12.0: @@ -4046,6 +4962,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,7 +4974,7 @@ snapshots: semver@7.7.2: {} - semver@7.7.4: {} + semver@7.8.5: {} send@1.2.0: dependencies: @@ -4084,6 +5004,8 @@ snapshots: transitivePeerDependencies: - supports-color + server-only@0.0.1: {} + set-cookie-parser@2.7.2: {} set-cookie-parser@3.0.1: {} @@ -4094,7 +5016,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 +5040,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 @@ -4183,6 +5139,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: @@ -4201,6 +5161,12 @@ snapshots: 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 +5186,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 +5206,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 +5236,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 +5248,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 +5277,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 +5288,8 @@ snapshots: undici-types@6.21.0: {} + unicode-emoji-modifier-base@1.0.0: {} + unpipe@1.0.0: {} unrun@0.2.27: @@ -4316,6 +5303,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 +5312,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 +5376,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 +5409,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..2c9fb92 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,3 +8,4 @@ overrides: chargebee-init: link:packages/cli '@chargebee/express': link:packages/express '@chargebee/nextjs': link:packages/nextjs + '@chargebee/openfeature': link:packages/openfeature From f124d7a8cbba3d15712ecfbbdbfb34a7fb4c28e8 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Tue, 11 Aug 2026 16:23:17 +1000 Subject: [PATCH 02/22] refactor openfeature package --- packages/openfeature/CHANGELOG.md | 2 +- packages/openfeature/README.md | 6 +- packages/openfeature/src/cache.ts | 251 +-------- packages/openfeature/src/cache/key.ts | 20 + packages/openfeature/src/cache/memory.ts | 74 +++ packages/openfeature/src/cache/redis.ts | 31 ++ packages/openfeature/src/cache/tiered.ts | 96 ++++ packages/openfeature/src/cache/types.ts | 23 + packages/openfeature/src/server.ts | 501 +----------------- packages/openfeature/src/server/loader.ts | 140 +++++ packages/openfeature/src/server/provider.ts | 287 ++++++++++ packages/openfeature/src/server/relay.ts | 60 +++ packages/openfeature/src/shared.ts | 379 +------------ packages/openfeature/src/shared/context.ts | 30 ++ packages/openfeature/src/shared/evaluation.ts | 198 +++++++ packages/openfeature/src/shared/snapshot.ts | 71 +++ packages/openfeature/src/shared/types.ts | 56 ++ packages/openfeature/src/web.ts | 221 +------- packages/openfeature/src/web/provider.ts | 218 ++++++++ packages/openfeature/test/cache.test.ts | 8 +- 20 files changed, 1367 insertions(+), 1305 deletions(-) create mode 100644 packages/openfeature/src/cache/key.ts create mode 100644 packages/openfeature/src/cache/memory.ts create mode 100644 packages/openfeature/src/cache/redis.ts create mode 100644 packages/openfeature/src/cache/tiered.ts create mode 100644 packages/openfeature/src/cache/types.ts create mode 100644 packages/openfeature/src/server/loader.ts create mode 100644 packages/openfeature/src/server/provider.ts create mode 100644 packages/openfeature/src/server/relay.ts create mode 100644 packages/openfeature/src/shared/context.ts create mode 100644 packages/openfeature/src/shared/evaluation.ts create mode 100644 packages/openfeature/src/shared/snapshot.ts create mode 100644 packages/openfeature/src/shared/types.ts create mode 100644 packages/openfeature/src/web/provider.ts diff --git a/packages/openfeature/CHANGELOG.md b/packages/openfeature/CHANGELOG.md index 9264d45..924add2 100644 --- a/packages/openfeature/CHANGELOG.md +++ b/packages/openfeature/CHANGELOG.md @@ -4,4 +4,4 @@ - Initial Chargebee Entitlements providers for the OpenFeature server and web SDKs. - Secure framework-neutral and Next.js 16 entitlement relay helpers. -- Layered in-memory and client-agnostic Redis caching. +- Tiered in-memory and client-agnostic Redis caching. diff --git a/packages/openfeature/README.md b/packages/openfeature/README.md index 25f8a2b..fd91d1f 100644 --- a/packages/openfeature/README.md +++ b/packages/openfeature/README.md @@ -2,7 +2,7 @@ `@chargebee/openfeature` exposes Chargebee Entitlements through the OpenFeature server and web SDKs. It supports customer-level consolidated entitlements, -subscription-level entitlements, layered memory/Redis caching, and an +subscription-level entitlements, tiered memory/Redis caching, and an authenticated browser relay for Next.js 16. ## Install @@ -95,7 +95,7 @@ shared Redis L2 cache for multi-instance or serverless deployments: ```ts import { createRedisEntitlementsCache, - LayeredEntitlementsCache, + TieredEntitlementsCache, } from "@chargebee/openfeature/cache"; const redisCache = createRedisEntitlementsCache({ @@ -108,7 +108,7 @@ const redisCache = createRedisEntitlementsCache({ }, }); -const cache = new LayeredEntitlementsCache({ +const cache = new TieredEntitlementsCache({ redis: redisCache, memoryTtlMs: 30_000, redisTtlMs: 5 * 60_000, diff --git a/packages/openfeature/src/cache.ts b/packages/openfeature/src/cache.ts index 2471aa7..18ec85a 100644 --- a/packages/openfeature/src/cache.ts +++ b/packages/openfeature/src/cache.ts @@ -1,236 +1,15 @@ -import { - type ChargebeeEntitlementsSnapshot, - type ChargebeeTarget, - isSnapshotExpired, - parseSerializedEntitlementsSnapshot, - serializeEntitlementsSnapshot, -} from "./shared"; - -export interface EntitlementsCache { - get(key: string): Promise; - set( - key: string, - value: ChargebeeEntitlementsSnapshot, - ttlMs: number, - ): Promise; - delete(key: string): Promise; - clear?(): Promise; -} - -export interface CacheLookup { - snapshot: ChargebeeEntitlementsSnapshot; - source: "memory" | "redis"; -} - -export interface MemoryEntitlementsCacheOptions { - maxEntries?: number; - now?: () => number; -} - -interface MemoryCacheEntry { - snapshot: ChargebeeEntitlementsSnapshot; - expiresAt: number; -} - -export class MemoryEntitlementsCache implements EntitlementsCache { - private readonly entries = new Map(); - private readonly maxEntries: number; - private readonly now: () => number; - - constructor(options: MemoryEntitlementsCacheOptions = {}) { - this.maxEntries = options.maxEntries ?? 500; - this.now = options.now ?? Date.now; - if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) { - throw new Error("maxEntries must be a positive integer"); - } - } - - async get(key: string): Promise { - const entry = this.entries.get(key); - if (!entry) return undefined; - - if ( - entry.expiresAt <= this.now() || - isSnapshotExpired(entry.snapshot, this.now()) - ) { - this.entries.delete(key); - return undefined; - } - - this.entries.delete(key); - this.entries.set(key, entry); - return entry.snapshot; - } - - async set( - key: string, - value: ChargebeeEntitlementsSnapshot, - ttlMs: number, - ): Promise { - if (ttlMs <= 0) return; - this.entries.delete(key); - this.entries.set(key, { - snapshot: value, - expiresAt: this.now() + ttlMs, - }); - - while (this.entries.size > this.maxEntries) { - const oldestKey = this.entries.keys().next().value; - if (oldestKey === undefined) break; - this.entries.delete(oldestKey); - } - } - - async delete(key: string): Promise { - this.entries.delete(key); - } - - async clear(): Promise { - this.entries.clear(); - } -} - -export interface RedisCacheCommands { - get(key: string): Promise; - set(key: string, value: string, ttlMs: number): Promise; - delete(key: string): Promise; -} - -export function createRedisEntitlementsCache( - commands: RedisCacheCommands, -): EntitlementsCache { - return { - async get(key) { - const value = await commands.get(key); - if (value == null) return undefined; - - try { - const snapshot = parseSerializedEntitlementsSnapshot(value); - if (isSnapshotExpired(snapshot)) { - await commands.delete(key); - return undefined; - } - return snapshot; - } catch { - await commands.delete(key); - return undefined; - } - }, - async set(key, value, ttlMs) { - await commands.set(key, serializeEntitlementsSnapshot(value), ttlMs); - }, - async delete(key) { - await commands.delete(key); - }, - }; -} - -export interface LayeredEntitlementsCacheOptions { - memory?: EntitlementsCache; - redis?: EntitlementsCache; - memoryTtlMs?: number; - redisTtlMs?: number; - onError?: (error: unknown, layer: "memory" | "redis") => void; -} - -export class LayeredEntitlementsCache { - private readonly memory: EntitlementsCache; - private readonly redis?: EntitlementsCache; - private readonly memoryTtlMs: number; - private readonly redisTtlMs: number; - private readonly onError?: ( - error: unknown, - layer: "memory" | "redis", - ) => void; - - constructor(options: LayeredEntitlementsCacheOptions = {}) { - this.memory = options.memory ?? new MemoryEntitlementsCache(); - this.redis = options.redis; - this.memoryTtlMs = options.memoryTtlMs ?? 30_000; - this.redisTtlMs = options.redisTtlMs ?? 300_000; - this.onError = options.onError; - } - - get snapshotTtlMs(): number { - return this.redis ? this.redisTtlMs : this.memoryTtlMs; - } - - async get(key: string): Promise { - try { - const memoryValue = await this.memory.get(key); - if (memoryValue) return { snapshot: memoryValue, source: "memory" }; - } catch (error) { - this.onError?.(error, "memory"); - } - - if (!this.redis) return undefined; - try { - const redisValue = await this.redis.get(key); - if (!redisValue) return undefined; - const remainingTtl = Date.parse(redisValue.expiresAt) - Date.now(); - await this.memory.set( - key, - redisValue, - Math.min(this.memoryTtlMs, remainingTtl), - ); - return { snapshot: redisValue, source: "redis" }; - } catch (error) { - this.onError?.(error, "redis"); - return undefined; - } - } - - async set( - key: string, - snapshot: ChargebeeEntitlementsSnapshot, - ): Promise { - if (this.redis) { - try { - await this.redis.set(key, snapshot, this.redisTtlMs); - } catch (error) { - this.onError?.(error, "redis"); - } - } - - try { - await this.memory.set(key, snapshot, this.memoryTtlMs); - } catch (error) { - this.onError?.(error, "memory"); - } - } - - async delete(key: string): Promise { - const deletions: Promise[] = [ - this.memory.delete(key).catch((error) => { - this.onError?.(error, "memory"); - }), - ]; - if (this.redis) { - deletions.push( - this.redis.delete(key).catch((error) => { - this.onError?.(error, "redis"); - }), - ); - } - await Promise.all(deletions); - } - - async clearMemory(): Promise { - await this.memory.clear?.(); - } -} - -export function createEntitlementsCacheKey( - target: ChargebeeTarget, - options: { - namespace?: string; - consolidateCustomerEntitlements?: boolean; - } = {}, -): string { - const namespace = options.namespace ?? "chargebee:openfeature:v1"; - const consolidated = options.consolidateCustomerEntitlements ?? true; - if (target.mode === "customer") { - return `${namespace}:customer:${encodeURIComponent(target.customerId)}:${consolidated ? "consolidated" : "individual"}`; - } - return `${namespace}:subscription:${encodeURIComponent(target.subscriptionId)}`; -} +export { createEntitlementsCacheKey } from "./cache/key"; +export { + MemoryEntitlementsCache, + type MemoryEntitlementsCacheOptions, +} from "./cache/memory"; +export { createRedisEntitlementsCache } from "./cache/redis"; +export { + TieredEntitlementsCache, + type TieredEntitlementsCacheOptions, +} from "./cache/tiered"; +export type { + CacheLookup, + EntitlementsCache, + RedisCacheCommands, +} from "./cache/types"; diff --git a/packages/openfeature/src/cache/key.ts b/packages/openfeature/src/cache/key.ts new file mode 100644 index 0000000..f367998 --- /dev/null +++ b/packages/openfeature/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:openfeature:v1"; + if (target.mode === "subscription") { + return `${namespace}:subscription:${encodeURIComponent(target.subscriptionId)}`; + } + + const view = + (options.consolidateCustomerEntitlements ?? true) + ? "consolidated" + : "individual"; + return `${namespace}:customer:${encodeURIComponent(target.customerId)}:${view}`; +} diff --git a/packages/openfeature/src/cache/memory.ts b/packages/openfeature/src/cache/memory.ts new file mode 100644 index 0000000..2727a01 --- /dev/null +++ b/packages/openfeature/src/cache/memory.ts @@ -0,0 +1,74 @@ +import { + type ChargebeeEntitlementsSnapshot, + isSnapshotExpired, +} from "../shared"; +import type { EntitlementsCache } from "./types"; + +export interface MemoryEntitlementsCacheOptions { + maxEntries?: number; + now?: () => number; +} + +interface MemoryCacheEntry { + snapshot: ChargebeeEntitlementsSnapshot; + expiresAt: number; +} + +export class MemoryEntitlementsCache implements EntitlementsCache { + private readonly entries = new Map(); + private readonly maxEntries: number; + private readonly now: () => number; + + constructor(options: MemoryEntitlementsCacheOptions = {}) { + this.maxEntries = options.maxEntries ?? 500; + this.now = options.now ?? Date.now; + if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) { + throw new Error("maxEntries must be a positive integer"); + } + } + + async get(key: string): Promise { + const entry = this.entries.get(key); + if (!entry) return undefined; + + if ( + entry.expiresAt <= this.now() || + isSnapshotExpired(entry.snapshot, this.now()) + ) { + this.entries.delete(key); + return undefined; + } + + // Moving a hit to the end makes Map insertion order an LRU list. + this.entries.delete(key); + this.entries.set(key, entry); + return entry.snapshot; + } + + async set( + key: string, + snapshot: ChargebeeEntitlementsSnapshot, + ttlMs: number, + ): Promise { + if (ttlMs <= 0) return; + this.entries.delete(key); + this.entries.set(key, { + snapshot, + expiresAt: this.now() + ttlMs, + }); + + while (this.entries.size > this.maxEntries) { + const oldestKey = this.entries.keys().next().value; + if (oldestKey === undefined) return; + this.entries.delete(oldestKey); + } + } + + async delete(key: string): Promise { + this.entries.delete(key); + } + + async clear(): Promise { + this.entries.clear(); + } +} diff --git a/packages/openfeature/src/cache/redis.ts b/packages/openfeature/src/cache/redis.ts new file mode 100644 index 0000000..dedbcfd --- /dev/null +++ b/packages/openfeature/src/cache/redis.ts @@ -0,0 +1,31 @@ +import { + isSnapshotExpired, + parseSerializedEntitlementsSnapshot, + serializeEntitlementsSnapshot, +} from "../shared"; +import type { EntitlementsCache, RedisCacheCommands } from "./types"; + +export function createRedisEntitlementsCache( + commands: RedisCacheCommands, +): EntitlementsCache { + return { + async get(key) { + const value = await commands.get(key); + if (value == null) return undefined; + + try { + const snapshot = parseSerializedEntitlementsSnapshot(value); + if (!isSnapshotExpired(snapshot)) return snapshot; + } catch { + // Corrupt or old cache values are treated as misses. + } + + await commands.delete(key); + return undefined; + }, + async set(key, value, ttlMs) { + await commands.set(key, serializeEntitlementsSnapshot(value), ttlMs); + }, + delete: (key) => commands.delete(key), + }; +} diff --git a/packages/openfeature/src/cache/tiered.ts b/packages/openfeature/src/cache/tiered.ts new file mode 100644 index 0000000..e6e104e --- /dev/null +++ b/packages/openfeature/src/cache/tiered.ts @@ -0,0 +1,96 @@ +import type { ChargebeeEntitlementsSnapshot } from "../shared"; +import { MemoryEntitlementsCache } from "./memory"; +import type { CacheLookup, EntitlementsCache } from "./types"; + +export interface TieredEntitlementsCacheOptions { + memory?: EntitlementsCache; + redis?: EntitlementsCache; + memoryTtlMs?: number; + redisTtlMs?: number; + onError?: (error: unknown, tier: "memory" | "redis") => void; +} + +export class TieredEntitlementsCache { + private readonly memory: EntitlementsCache; + private readonly redis?: EntitlementsCache; + private readonly memoryTtlMs: number; + private readonly redisTtlMs: number; + private readonly onError?: (error: unknown, tier: "memory" | "redis") => void; + + constructor(options: TieredEntitlementsCacheOptions = {}) { + this.memory = options.memory ?? new MemoryEntitlementsCache(); + this.redis = options.redis; + this.memoryTtlMs = options.memoryTtlMs ?? 30_000; + this.redisTtlMs = options.redisTtlMs ?? 300_000; + this.onError = options.onError; + } + + get snapshotTtlMs(): number { + return this.redis ? this.redisTtlMs : this.memoryTtlMs; + } + + async get(key: string): Promise { + try { + const snapshot = await this.memory.get(key); + if (snapshot) return { snapshot, source: "memory" }; + } catch (error) { + this.onError?.(error, "memory"); + } + + if (!this.redis) return undefined; + try { + const snapshot = await this.redis.get(key); + if (!snapshot) return undefined; + await this.memory.set( + key, + snapshot, + Math.min(this.memoryTtlMs, Date.parse(snapshot.expiresAt) - Date.now()), + ); + return { snapshot, source: "redis" }; + } catch (error) { + this.onError?.(error, "redis"); + return undefined; + } + } + + async set( + key: string, + snapshot: ChargebeeEntitlementsSnapshot, + ): Promise { + if (this.redis) { + try { + await this.redis.set(key, snapshot, this.redisTtlMs); + } catch (error) { + this.onError?.(error, "redis"); + } + } + + try { + await this.memory.set(key, snapshot, this.memoryTtlMs); + } catch (error) { + this.onError?.(error, "memory"); + } + } + + async delete(key: string): Promise { + const remove = async ( + cache: EntitlementsCache, + tier: "memory" | "redis", + ) => { + try { + await cache.delete(key); + } catch (error) { + this.onError?.(error, tier); + } + }; + + await Promise.all([ + remove(this.memory, "memory"), + ...(this.redis ? [remove(this.redis, "redis")] : []), + ]); + } + + async clearMemory(): Promise { + await this.memory.clear?.(); + } +} diff --git a/packages/openfeature/src/cache/types.ts b/packages/openfeature/src/cache/types.ts new file mode 100644 index 0000000..e6c4260 --- /dev/null +++ b/packages/openfeature/src/cache/types.ts @@ -0,0 +1,23 @@ +import type { ChargebeeEntitlementsSnapshot } from "../shared"; + +export interface EntitlementsCache { + get(key: string): Promise; + set( + key: string, + value: ChargebeeEntitlementsSnapshot, + ttlMs: number, + ): Promise; + delete(key: string): Promise; + clear?(): Promise; +} + +export interface CacheLookup { + snapshot: ChargebeeEntitlementsSnapshot; + source: "memory" | "redis"; +} + +export interface RedisCacheCommands { + get(key: string): Promise; + set(key: string, value: string, ttlMs: number): Promise; + delete(key: string): Promise; +} diff --git a/packages/openfeature/src/server.ts b/packages/openfeature/src/server.ts index acd97eb..2361017 100644 --- a/packages/openfeature/src/server.ts +++ b/packages/openfeature/src/server.ts @@ -1,496 +1,17 @@ -import type { - ErrorCode, - EvaluationContext, - JsonValue, - Logger, - Provider, - ResolutionDetails, -} from "@openfeature/server-sdk"; -import type Chargebee from "chargebee"; -import type { CustomerEntitlement, SubscriptionEntitlement } from "chargebee"; -import { - createEntitlementsCacheKey, - LayeredEntitlementsCache, - type LayeredEntitlementsCacheOptions, -} from "./cache"; -import { +export { + type ChargebeeEntitlementsClient, + ChargebeeEntitlementsProvider, + type ChargebeeEntitlementsProviderOptions, + type EntitlementsSnapshotResult, +} from "./server/provider"; +export { + type CreateEntitlementsRelayHandlerOptions, + createEntitlementsRelayHandler, +} from "./server/relay"; +export { CHARGEBEE_CONTEXT_KEYS, type ChargebeeEntitlement, type ChargebeeEntitlementsSnapshot, type ChargebeeEvaluationMode, type ChargebeeTarget, - createEntitlementsSnapshot, - type EntitlementResolution, - getTargetFromContext, - resolveBooleanEntitlement, - resolveNumberEntitlement, - resolveObjectEntitlement, - resolveStringEntitlement, } from "./shared"; - -export interface ChargebeeEntitlementsClient - extends Pick {} - -export interface ChargebeeEntitlementsProviderOptions { - chargebeeClient: ChargebeeEntitlementsClient; - defaultMode?: ChargebeeEvaluationMode; - consolidateCustomerEntitlements?: boolean; - resolveTarget?: (context: EvaluationContext) => ChargebeeTarget; - cache?: LayeredEntitlementsCache | LayeredEntitlementsCacheOptions; - cacheNamespace?: string; - pageSize?: number; - maxPages?: number; -} - -export interface EntitlementsSnapshotResult { - snapshot: ChargebeeEntitlementsSnapshot; - source: "api" | "memory" | "redis"; -} - -interface InFlightSnapshotRequest { - promise: Promise; - state: { invalidated: boolean }; -} - -class InvalidEntitlementContextError extends Error {} - -export class ChargebeeEntitlementsProvider implements Provider { - readonly metadata = { name: "Chargebee Entitlements" } as const; - readonly runsOn = "server" as const; - - private readonly chargebeeClient: ChargebeeEntitlementsClient; - private readonly defaultMode: ChargebeeEvaluationMode; - private readonly consolidateCustomerEntitlements: boolean; - private readonly resolveTargetOption?: ( - context: EvaluationContext, - ) => ChargebeeTarget; - private readonly cache: LayeredEntitlementsCache; - private readonly cacheNamespace?: string; - private readonly pageSize: number; - private readonly maxPages: number; - private readonly inFlight = new Map(); - - constructor(options: ChargebeeEntitlementsProviderOptions) { - if (!options?.chargebeeClient) { - throw new Error("chargebeeClient is required"); - } - this.chargebeeClient = options.chargebeeClient; - this.defaultMode = options.defaultMode ?? "customer"; - this.consolidateCustomerEntitlements = - options.consolidateCustomerEntitlements ?? true; - this.resolveTargetOption = options.resolveTarget; - this.cache = - options.cache instanceof LayeredEntitlementsCache - ? options.cache - : new LayeredEntitlementsCache(options.cache); - this.cacheNamespace = options.cacheNamespace; - this.pageSize = options.pageSize ?? 100; - this.maxPages = options.maxPages ?? 50; - - if ( - !Number.isInteger(this.pageSize) || - this.pageSize < 1 || - this.pageSize > 100 - ) { - throw new Error("pageSize must be an integer between 1 and 100"); - } - if (!Number.isInteger(this.maxPages) || this.maxPages < 1) { - throw new Error("maxPages must be a positive integer"); - } - } - - async initialize(): Promise { - // The server provider is context-dynamic, so initialization only validates - // constructor configuration. Entitlements are loaded for each target lazily. - } - - async onClose(): Promise { - for (const request of this.inFlight.values()) { - request.state.invalidated = true; - } - this.inFlight.clear(); - await this.cache.clearMemory(); - } - - async resolveBooleanEvaluation( - flagKey: string, - defaultValue: boolean, - context: EvaluationContext, - logger: Logger, - ): Promise> { - try { - const result = await this.getSnapshot(context, logger); - return this.toResolution( - resolveBooleanEntitlement( - result.snapshot, - flagKey, - defaultValue, - result.source, - ), - ); - } catch (error) { - return this.errorResolution(defaultValue, error); - } - } - - async resolveStringEvaluation( - flagKey: string, - defaultValue: string, - context: EvaluationContext, - logger: Logger, - ): Promise> { - try { - const result = await this.getSnapshot(context, logger); - return this.toResolution( - resolveStringEntitlement( - result.snapshot, - flagKey, - defaultValue, - result.source, - ), - ); - } catch (error) { - return this.errorResolution(defaultValue, error); - } - } - - async resolveNumberEvaluation( - flagKey: string, - defaultValue: number, - context: EvaluationContext, - logger: Logger, - ): Promise> { - try { - const result = await this.getSnapshot(context, logger); - return this.toResolution( - resolveNumberEntitlement( - result.snapshot, - flagKey, - defaultValue, - result.source, - ), - ); - } catch (error) { - return this.errorResolution(defaultValue, error); - } - } - - async resolveObjectEvaluation( - flagKey: string, - defaultValue: T, - context: EvaluationContext, - logger: Logger, - ): Promise> { - try { - const result = await this.getSnapshot(context, logger); - return this.toResolution( - resolveObjectEntitlement( - result.snapshot, - flagKey, - defaultValue, - result.source, - ), - ); - } catch (error) { - return this.errorResolution(defaultValue, error); - } - } - - async getSnapshot( - context: EvaluationContext, - logger?: Logger, - ): Promise { - const target = this.resolveTarget(context); - const key = this.cacheKey(target); - const cached = await this.cache.get(key); - if (cached) return cached; - - const existingRequest = this.inFlight.get(key); - if (existingRequest) return existingRequest.promise; - - const state = { invalidated: false }; - const request = this.fetchSnapshot(target, key, state, logger).finally( - () => { - if (this.inFlight.get(key)?.promise === request) { - this.inFlight.delete(key); - } - }, - ); - this.inFlight.set(key, { promise: request, state }); - return request; - } - - async getRelaySnapshot( - context: EvaluationContext, - ttlMs = 60_000, - logger?: Logger, - ): Promise { - const { snapshot } = await this.getSnapshot(context, logger); - const relayExpiresAt = Math.min( - Date.parse(snapshot.expiresAt), - Date.now() + ttlMs, - ); - return { - ...snapshot, - expiresAt: new Date(relayExpiresAt).toISOString(), - }; - } - - async invalidate(target: ChargebeeTarget): Promise { - const key = this.cacheKey(target); - const inFlightRequest = this.inFlight.get(key); - if (inFlightRequest) inFlightRequest.state.invalidated = true; - this.inFlight.delete(key); - await this.cache.delete(key); - } - - async clearMemoryCache(): Promise { - await this.cache.clearMemory(); - } - - private resolveTarget(context: EvaluationContext): ChargebeeTarget { - try { - return this.resolveTargetOption - ? this.resolveTargetOption(context) - : getTargetFromContext(context, this.defaultMode); - } catch (error) { - throw new InvalidEntitlementContextError( - error instanceof Error ? error.message : "Invalid evaluation context", - ); - } - } - - private cacheKey(target: ChargebeeTarget): string { - return createEntitlementsCacheKey(target, { - namespace: this.cacheNamespace, - consolidateCustomerEntitlements: this.consolidateCustomerEntitlements, - }); - } - - private async fetchSnapshot( - target: ChargebeeTarget, - key: string, - state: { invalidated: boolean }, - logger?: Logger, - ): Promise { - const entitlements = - target.mode === "customer" - ? await this.fetchCustomerEntitlements(target.customerId, logger) - : await this.fetchSubscriptionEntitlements( - target.subscriptionId, - logger, - ); - const snapshot = createEntitlementsSnapshot( - target.mode, - entitlements, - this.cache.snapshotTtlMs, - ); - if (!state.invalidated) { - await this.cache.set(key, snapshot); - } - return { snapshot, source: "api" }; - } - - private async fetchCustomerEntitlements( - customerId: string, - logger?: Logger, - ): Promise { - const entitlements: ChargebeeEntitlement[] = []; - let offset: string | undefined; - - for (let page = 0; page < this.maxPages; page += 1) { - const response = - await this.chargebeeClient.customerEntitlement.entitlementsForCustomer( - customerId, - { - limit: this.pageSize, - offset, - consolidate_entitlements: this.consolidateCustomerEntitlements, - }, - ); - for (const item of response.list) { - const normalized = this.normalizeCustomerEntitlement( - item.customer_entitlement, - ); - if (normalized) entitlements.push(normalized); - else - logger?.warn("Chargebee customer entitlement omitted a feature_id"); - } - - offset = response.next_offset; - if (!offset) return entitlements; - } - - throw new Error( - `Chargebee customer entitlement pagination exceeded ${this.maxPages} pages`, - ); - } - - private async fetchSubscriptionEntitlements( - subscriptionId: string, - logger?: Logger, - ): Promise { - const entitlements: ChargebeeEntitlement[] = []; - let offset: string | undefined; - - for (let page = 0; page < this.maxPages; page += 1) { - const response = - await this.chargebeeClient.subscriptionEntitlement.subscriptionEntitlementsForSubscription( - subscriptionId, - { limit: this.pageSize, offset }, - ); - for (const item of response.list) { - const normalized = this.normalizeSubscriptionEntitlement( - item.subscription_entitlement, - ); - if (normalized) entitlements.push(normalized); - else - logger?.warn( - "Chargebee subscription entitlement omitted a feature_id", - ); - } - - offset = response.next_offset; - if (!offset) return entitlements; - } - - throw new Error( - `Chargebee subscription entitlement pagination exceeded ${this.maxPages} pages`, - ); - } - - private normalizeCustomerEntitlement( - entitlement: CustomerEntitlement, - ): ChargebeeEntitlement | undefined { - if (!entitlement.feature_id) return undefined; - return { - featureId: entitlement.feature_id, - isEnabled: entitlement.is_enabled, - ...(entitlement.value !== undefined ? { value: entitlement.value } : {}), - ...(entitlement.name !== undefined ? { name: entitlement.name } : {}), - }; - } - - private normalizeSubscriptionEntitlement( - entitlement: SubscriptionEntitlement, - ): ChargebeeEntitlement | undefined { - if (!entitlement.feature_id) return undefined; - return { - featureId: entitlement.feature_id, - isEnabled: entitlement.is_enabled, - isOverridden: entitlement.is_overridden, - ...(entitlement.value !== undefined ? { value: entitlement.value } : {}), - ...(entitlement.name !== undefined ? { name: entitlement.name } : {}), - ...(entitlement.feature_name !== undefined - ? { featureName: entitlement.feature_name } - : {}), - ...(entitlement.feature_unit !== undefined - ? { featureUnit: entitlement.feature_unit } - : {}), - ...(entitlement.feature_type !== undefined - ? { featureType: entitlement.feature_type } - : {}), - ...(entitlement.expires_at !== undefined - ? { expiresAt: entitlement.expires_at } - : {}), - }; - } - - private errorResolution( - defaultValue: T, - error: unknown, - ): ResolutionDetails { - const invalidContext = error instanceof InvalidEntitlementContextError; - return { - value: defaultValue, - reason: "ERROR", - errorCode: (invalidContext ? "INVALID_CONTEXT" : "GENERAL") as ErrorCode, - errorMessage: - error instanceof Error - ? error.message - : "Unable to evaluate Chargebee entitlement", - }; - } - - private toResolution( - resolution: EntitlementResolution, - ): ResolutionDetails { - return { - ...resolution, - errorCode: resolution.errorCode as ErrorCode | undefined, - }; - } -} - -export interface CreateEntitlementsRelayHandlerOptions { - provider: ChargebeeEntitlementsProvider; - resolveContext: ( - request: Request, - ) => EvaluationContext | null | Promise; - snapshotTtlMs?: number; - onError?: (error: unknown, request: Request) => Response | Promise; -} - -const PRIVATE_NO_STORE_HEADERS = { - "Cache-Control": "private, no-cache, no-store, max-age=0, must-revalidate", - "Content-Type": "application/json", -} as const; - -export function createEntitlementsRelayHandler( - options: CreateEntitlementsRelayHandlerOptions, -): (request: Request) => Promise { - return async (request: Request): Promise => { - try { - if (request.method !== "GET") { - return Response.json( - { error: "Method not allowed" }, - { - status: 405, - headers: { ...PRIVATE_NO_STORE_HEADERS, Allow: "GET" }, - }, - ); - } - - const url = new URL(request.url); - for (const key of Object.values(CHARGEBEE_CONTEXT_KEYS)) { - if (url.searchParams.has(key)) { - return Response.json( - { error: "Billing identity must be resolved by the server" }, - { status: 400, headers: PRIVATE_NO_STORE_HEADERS }, - ); - } - } - - const context = await options.resolveContext(request); - if (!context) { - return Response.json( - { error: "Unauthorized" }, - { status: 401, headers: PRIVATE_NO_STORE_HEADERS }, - ); - } - - const snapshot = await options.provider.getRelaySnapshot( - context, - options.snapshotTtlMs, - ); - return Response.json(snapshot, { - status: 200, - headers: PRIVATE_NO_STORE_HEADERS, - }); - } catch (error) { - if (options.onError) return options.onError(error, request); - return Response.json( - { error: "Unable to load entitlements" }, - { status: 502, headers: PRIVATE_NO_STORE_HEADERS }, - ); - } - }; -} - -export { CHARGEBEE_CONTEXT_KEYS }; -export type { - ChargebeeEntitlement, - ChargebeeEntitlementsSnapshot, - ChargebeeEvaluationMode, - ChargebeeTarget, -}; diff --git a/packages/openfeature/src/server/loader.ts b/packages/openfeature/src/server/loader.ts new file mode 100644 index 0000000..7be11e3 --- /dev/null +++ b/packages/openfeature/src/server/loader.ts @@ -0,0 +1,140 @@ +import type { Logger } from "@openfeature/server-sdk"; +import type Chargebee from "chargebee"; +import type { CustomerEntitlement, SubscriptionEntitlement } from "chargebee"; +import type { ChargebeeEntitlement, ChargebeeTarget } 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; +} + +function withoutUndefined(value: T): T { + return Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined), + ) as T; +} + +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, + logger?: Logger, + ): Promise { + return target.mode === "customer" + ? this.loadCustomer(target.customerId, logger) + : this.loadSubscription(target.subscriptionId, logger); + } + + private loadCustomer(customerId: string, logger?: Logger) { + return this.collect( + (offset) => + this.options.chargebeeClient.customerEntitlement.entitlementsForCustomer( + customerId, + { + limit: this.options.pageSize, + offset, + consolidate_entitlements: + this.options.consolidateCustomerEntitlements, + }, + ), + (item: { customer_entitlement: CustomerEntitlement }) => + this.normalizeCustomer(item.customer_entitlement), + "customer", + logger, + ); + } + + private loadSubscription(subscriptionId: string, logger?: Logger) { + return this.collect( + (offset) => + this.options.chargebeeClient.subscriptionEntitlement.subscriptionEntitlementsForSubscription( + subscriptionId, + { limit: this.options.pageSize, offset }, + ), + (item: { subscription_entitlement: SubscriptionEntitlement }) => + this.normalizeSubscription(item.subscription_entitlement), + "subscription", + logger, + ); + } + + private async collect( + loadPage: (offset?: string) => Promise>, + normalize: (item: T) => ChargebeeEntitlement | undefined, + scope: ChargebeeTarget["mode"], + logger?: Logger, + ): 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 + 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`, + ); + } + + private normalizeCustomer( + entitlement: CustomerEntitlement, + ): ChargebeeEntitlement | undefined { + if (!entitlement.feature_id) return undefined; + return withoutUndefined({ + featureId: entitlement.feature_id, + isEnabled: entitlement.is_enabled, + value: entitlement.value, + name: entitlement.name, + }); + } + + private normalizeSubscription( + entitlement: SubscriptionEntitlement, + ): ChargebeeEntitlement | undefined { + if (!entitlement.feature_id) return undefined; + return withoutUndefined({ + featureId: entitlement.feature_id, + isEnabled: entitlement.is_enabled, + 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, + }); + } +} diff --git a/packages/openfeature/src/server/provider.ts b/packages/openfeature/src/server/provider.ts new file mode 100644 index 0000000..936512e --- /dev/null +++ b/packages/openfeature/src/server/provider.ts @@ -0,0 +1,287 @@ +import type { + ErrorCode, + EvaluationContext, + JsonValue, + Logger, + Provider, + ResolutionDetails, +} from "@openfeature/server-sdk"; +import { + createEntitlementsCacheKey, + TieredEntitlementsCache, + type TieredEntitlementsCacheOptions, +} from "../cache"; +import { + type ChargebeeEntitlementsSnapshot, + type ChargebeeEvaluationMode, + type ChargebeeTarget, + createEntitlementsSnapshot, + type EntitlementResolution, + getTargetFromContext, + resolveBooleanEntitlement, + resolveNumberEntitlement, + resolveObjectEntitlement, + resolveStringEntitlement, +} from "../shared"; +import { + type ChargebeeEntitlementsClient, + ChargebeeEntitlementsLoader, +} from "./loader"; + +export interface ChargebeeEntitlementsProviderOptions { + chargebeeClient: ChargebeeEntitlementsClient; + defaultMode?: ChargebeeEvaluationMode; + consolidateCustomerEntitlements?: boolean; + resolveTarget?: (context: EvaluationContext) => ChargebeeTarget; + cache?: TieredEntitlementsCache | TieredEntitlementsCacheOptions; + cacheNamespace?: string; + pageSize?: number; + maxPages?: number; +} + +export interface EntitlementsSnapshotResult { + snapshot: ChargebeeEntitlementsSnapshot; + source: "api" | "memory" | "redis"; +} + +interface InFlightSnapshotRequest { + promise: Promise; + state: { invalidated: boolean }; +} + +class InvalidEntitlementContextError extends Error {} + +export class ChargebeeEntitlementsProvider implements Provider { + readonly metadata = { name: "Chargebee Entitlements" } as const; + readonly runsOn = "server" as const; + + private readonly defaultMode: ChargebeeEvaluationMode; + private readonly consolidateCustomerEntitlements: boolean; + private readonly resolveTargetOption?: ( + context: EvaluationContext, + ) => ChargebeeTarget; + private readonly cache: TieredEntitlementsCache; + private readonly cacheNamespace?: string; + private readonly loader: ChargebeeEntitlementsLoader; + private readonly inFlight = new Map(); + + constructor(options: ChargebeeEntitlementsProviderOptions) { + if (!options?.chargebeeClient) + throw new Error("chargebeeClient is required"); + + this.defaultMode = options.defaultMode ?? "customer"; + this.consolidateCustomerEntitlements = + options.consolidateCustomerEntitlements ?? true; + this.resolveTargetOption = options.resolveTarget; + this.cache = + options.cache instanceof TieredEntitlementsCache + ? options.cache + : new TieredEntitlementsCache(options.cache); + this.cacheNamespace = options.cacheNamespace; + this.loader = new ChargebeeEntitlementsLoader({ + chargebeeClient: options.chargebeeClient, + consolidateCustomerEntitlements: this.consolidateCustomerEntitlements, + pageSize: options.pageSize ?? 100, + maxPages: options.maxPages ?? 50, + }); + } + + async onClose(): Promise { + for (const request of this.inFlight.values()) { + request.state.invalidated = true; + } + this.inFlight.clear(); + await this.cache.clearMemory(); + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + logger: Logger, + ): Promise> { + return this.evaluate(defaultValue, context, logger, (result) => + resolveBooleanEntitlement( + result.snapshot, + flagKey, + defaultValue, + result.source, + ), + ); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context: EvaluationContext, + logger: Logger, + ): Promise> { + return this.evaluate(defaultValue, context, logger, (result) => + resolveStringEntitlement( + result.snapshot, + flagKey, + defaultValue, + result.source, + ), + ); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context: EvaluationContext, + logger: Logger, + ): Promise> { + return this.evaluate(defaultValue, context, logger, (result) => + resolveNumberEntitlement( + result.snapshot, + flagKey, + defaultValue, + result.source, + ), + ); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + logger: Logger, + ): Promise> { + return this.evaluate(defaultValue, context, logger, (result) => + resolveObjectEntitlement( + result.snapshot, + flagKey, + defaultValue, + result.source, + ), + ); + } + + async getSnapshot( + context: EvaluationContext, + logger?: Logger, + ): Promise { + const target = this.resolveTarget(context); + const key = this.cacheKey(target); + const cached = await this.cache.get(key); + if (cached) return cached; + + const current = this.inFlight.get(key); + if (current) return current.promise; + + const state = { invalidated: false }; + const promise = this.loadSnapshot(target, key, state, logger).finally( + () => { + if (this.inFlight.get(key)?.promise === promise) { + this.inFlight.delete(key); + } + }, + ); + this.inFlight.set(key, { promise, state }); + return promise; + } + + async getRelaySnapshot( + context: EvaluationContext, + ttlMs = 60_000, + logger?: Logger, + ): Promise { + const { snapshot } = await this.getSnapshot(context, logger); + return { + ...snapshot, + expiresAt: new Date( + Math.min(Date.parse(snapshot.expiresAt), Date.now() + ttlMs), + ).toISOString(), + }; + } + + async invalidate(target: ChargebeeTarget): Promise { + const key = this.cacheKey(target); + const current = this.inFlight.get(key); + if (current) current.state.invalidated = true; + this.inFlight.delete(key); + await this.cache.delete(key); + } + + clearMemoryCache(): Promise { + return this.cache.clearMemory(); + } + + private async evaluate( + defaultValue: T, + context: EvaluationContext, + logger: Logger, + resolve: (result: EntitlementsSnapshotResult) => EntitlementResolution, + ): Promise> { + try { + return this.toResolution( + resolve(await this.getSnapshot(context, logger)), + ); + } catch (error) { + return this.errorResolution(defaultValue, error); + } + } + + private resolveTarget(context: EvaluationContext): ChargebeeTarget { + try { + return this.resolveTargetOption + ? this.resolveTargetOption(context) + : getTargetFromContext(context, this.defaultMode); + } catch (error) { + throw new InvalidEntitlementContextError( + error instanceof Error ? error.message : "Invalid evaluation context", + ); + } + } + + private cacheKey(target: ChargebeeTarget): string { + return createEntitlementsCacheKey(target, { + namespace: this.cacheNamespace, + consolidateCustomerEntitlements: this.consolidateCustomerEntitlements, + }); + } + + private async loadSnapshot( + target: ChargebeeTarget, + key: string, + state: { invalidated: boolean }, + logger?: Logger, + ): Promise { + const snapshot = createEntitlementsSnapshot( + target.mode, + await this.loader.load(target, logger), + this.cache.snapshotTtlMs, + ); + if (!state.invalidated) await this.cache.set(key, snapshot); + return { snapshot, source: "api" }; + } + + private errorResolution( + defaultValue: T, + error: unknown, + ): ResolutionDetails { + return { + value: defaultValue, + reason: "ERROR", + errorCode: (error instanceof InvalidEntitlementContextError + ? "INVALID_CONTEXT" + : "GENERAL") as ErrorCode, + errorMessage: + error instanceof Error + ? error.message + : "Unable to evaluate Chargebee entitlement", + }; + } + + private toResolution( + resolution: EntitlementResolution, + ): ResolutionDetails { + return { + ...resolution, + errorCode: resolution.errorCode as ErrorCode | undefined, + }; + } +} + +export type { ChargebeeEntitlementsClient }; diff --git a/packages/openfeature/src/server/relay.ts b/packages/openfeature/src/server/relay.ts new file mode 100644 index 0000000..718bf57 --- /dev/null +++ b/packages/openfeature/src/server/relay.ts @@ -0,0 +1,60 @@ +import type { EvaluationContext } from "@openfeature/server-sdk"; +import { CHARGEBEE_CONTEXT_KEYS } from "../shared"; +import type { ChargebeeEntitlementsProvider } from "./provider"; + +export interface CreateEntitlementsRelayHandlerOptions { + provider: ChargebeeEntitlementsProvider; + resolveContext: ( + request: Request, + ) => EvaluationContext | null | Promise; + snapshotTtlMs?: number; + onError?: (error: unknown, request: Request) => Response | Promise; +} + +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( + options: CreateEntitlementsRelayHandlerOptions, +): (request: Request) => Promise { + 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 ( + Object.values(CHARGEBEE_CONTEXT_KEYS).some((key) => search.has(key)) + ) { + return json( + { error: "Billing identity must be resolved by the server" }, + 400, + ); + } + + const context = await options.resolveContext(request); + if (!context) return json({ error: "Unauthorized" }, 401); + + return json( + await options.provider.getRelaySnapshot(context, options.snapshotTtlMs), + 200, + ); + } catch (error) { + return options.onError + ? options.onError(error, request) + : json({ error: "Unable to load entitlements" }, 502); + } + }; +} diff --git a/packages/openfeature/src/shared.ts b/packages/openfeature/src/shared.ts index c42d45d..de38973 100644 --- a/packages/openfeature/src/shared.ts +++ b/packages/openfeature/src/shared.ts @@ -1,354 +1,25 @@ -import { z } from "zod"; - -export const CHARGEBEE_CONTEXT_KEYS = { - customerId: "chargebeeCustomerId", - subscriptionId: "chargebeeSubscriptionId", - evaluationMode: "chargebeeEvaluationMode", -} as const; - -export type ChargebeeEvaluationMode = "customer" | "subscription"; - -export type ChargebeeTarget = - | { mode: "customer"; customerId: string } - | { mode: "subscription"; subscriptionId: string }; - -export type EvaluationContextLike = Record & { - targetingKey?: string; -}; - -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; - targetMode: ChargebeeEvaluationMode; - entitlements: Record; -} - -export type EntitlementCacheSource = "api" | "memory" | "redis" | "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; -} - -const entitlementSchema = z.object({ - featureId: z.string().min(1), - value: z.string().optional(), - name: z.string().optional(), - featureName: z.string().optional(), - featureUnit: z.string().optional(), - featureType: z.string().optional(), - isEnabled: z.boolean(), - isOverridden: z.boolean().optional(), - expiresAt: z.number().int().optional(), -}); - -const snapshotSchema = z.object({ - schemaVersion: z.literal(1), - generatedAt: z.iso.datetime(), - expiresAt: z.iso.datetime(), - targetMode: z.enum(["customer", "subscription"]), - entitlements: z.record(z.string(), entitlementSchema), -}); - -export function parseEntitlementsSnapshot( - input: unknown, -): ChargebeeEntitlementsSnapshot { - return snapshotSchema.parse(input); -} - -export function serializeEntitlementsSnapshot( - snapshot: ChargebeeEntitlementsSnapshot, -): string { - return JSON.stringify(snapshot); -} - -export function parseSerializedEntitlementsSnapshot( - value: string, -): ChargebeeEntitlementsSnapshot { - return parseEntitlementsSnapshot(JSON.parse(value)); -} - -export function createEntitlementsSnapshot( - targetMode: ChargebeeEvaluationMode, - entitlements: Iterable, - ttlMs: number, - now = Date.now(), -): ChargebeeEntitlementsSnapshot { - const entries: Record = {}; - for (const entitlement of entitlements) { - entries[entitlement.featureId] = entitlement; - } - - return { - schemaVersion: 1, - generatedAt: new Date(now).toISOString(), - expiresAt: new Date(now + ttlMs).toISOString(), - targetMode, - entitlements: entries, - }; -} - -export function isSnapshotExpired( - snapshot: ChargebeeEntitlementsSnapshot, - now = Date.now(), -): boolean { - return Date.parse(snapshot.expiresAt) <= now; -} - -export function getTargetFromContext( - context: EvaluationContextLike, - defaultMode: ChargebeeEvaluationMode = "customer", -): ChargebeeTarget { - const explicitMode = context[CHARGEBEE_CONTEXT_KEYS.evaluationMode]; - const mode = - explicitMode === "customer" || explicitMode === "subscription" - ? explicitMode - : defaultMode; - - if (mode === "customer") { - const customerId = context[CHARGEBEE_CONTEXT_KEYS.customerId]; - if (typeof customerId !== "string" || customerId.length === 0) { - throw new Error( - `Evaluation context requires a non-empty ${CHARGEBEE_CONTEXT_KEYS.customerId}`, - ); - } - return { mode, customerId }; - } - - const subscriptionId = context[CHARGEBEE_CONTEXT_KEYS.subscriptionId]; - if (typeof subscriptionId !== "string" || subscriptionId.length === 0) { - throw new Error( - `Evaluation context requires a non-empty ${CHARGEBEE_CONTEXT_KEYS.subscriptionId}`, - ); - } - return { mode, subscriptionId }; -} - -function metadataFor( - entitlement: ChargebeeEntitlement, - source: EntitlementCacheSource, -): Record { - const metadata: Record = { - chargebeeFeatureId: entitlement.featureId, - chargebeeEnabled: entitlement.isEnabled, - cacheSource: source, - }; - if (entitlement.value !== undefined) { - metadata.chargebeeValue = entitlement.value; - } - if (entitlement.featureType !== undefined) { - metadata.chargebeeFeatureType = entitlement.featureType; - } - if (entitlement.featureUnit !== undefined) { - metadata.chargebeeFeatureUnit = entitlement.featureUnit; - } - if (entitlement.isOverridden !== undefined) { - metadata.chargebeeOverridden = entitlement.isOverridden; - } - if (entitlement.expiresAt !== undefined) { - metadata.chargebeeExpiresAt = entitlement.expiresAt; - } - return metadata; -} - -function errorResolution( - defaultValue: T, - errorCode: EntitlementErrorCode, - errorMessage: string, -): EntitlementResolution { - return { - value: defaultValue, - reason: "ERROR", - errorCode, - errorMessage, - }; -} - -function findEnabledEntitlement( - snapshot: ChargebeeEntitlementsSnapshot, - flagKey: string, - defaultValue: T, - source: EntitlementCacheSource, - now = Date.now(), -): - | { - entitlement: ChargebeeEntitlement; - metadata: Record; - } - | EntitlementResolution { - const entitlement = snapshot.entitlements[flagKey]; - if (!entitlement) { - return errorResolution( - defaultValue, - "FLAG_NOT_FOUND", - `Chargebee feature ${flagKey} was not found`, - ); - } - - const metadata = metadataFor(entitlement, source); - const isExpired = - entitlement.expiresAt !== undefined && entitlement.expiresAt * 1000 <= now; - if (!entitlement.isEnabled || isExpired) { - return { - value: defaultValue, - variant: "disabled", - reason: "DISABLED", - flagMetadata: metadata, - }; - } - - return { entitlement, metadata }; -} - -function successReason(source: EntitlementCacheSource): string { - return source === "api" ? "TARGETING_MATCH" : "CACHED"; -} - -export function resolveBooleanEntitlement( - snapshot: ChargebeeEntitlementsSnapshot, - flagKey: string, - defaultValue: boolean, - source: EntitlementCacheSource, -): EntitlementResolution { - const found = findEnabledEntitlement(snapshot, flagKey, defaultValue, source); - if (!("entitlement" in found)) return found; - - const normalized = found.entitlement.value?.trim().toLowerCase(); - if ( - (normalized === undefined || normalized === "") && - (found.entitlement.featureType === undefined || - found.entitlement.featureType === "switch") - ) { - return { - value: true, - variant: "enabled", - reason: successReason(source), - flagMetadata: found.metadata, - }; - } - if ( - normalized !== "true" && - normalized !== "false" && - normalized !== "available" - ) { - return errorResolution( - defaultValue, - "TYPE_MISMATCH", - `Chargebee feature ${flagKey} is not a boolean entitlement`, - ); - } - - const value = normalized === "true" || normalized === "available"; - return { - value, - variant: value ? "enabled" : "disabled", - reason: successReason(source), - flagMetadata: found.metadata, - }; -} - -export function resolveStringEntitlement( - snapshot: ChargebeeEntitlementsSnapshot, - flagKey: string, - defaultValue: string, - source: EntitlementCacheSource, -): EntitlementResolution { - const found = findEnabledEntitlement(snapshot, flagKey, defaultValue, source); - if (!("entitlement" in found)) return found; - - if (found.entitlement.value === undefined) { - return errorResolution( - defaultValue, - "PARSE_ERROR", - `Chargebee feature ${flagKey} has no value`, - ); - } - - return { - value: found.entitlement.value, - variant: found.entitlement.value, - reason: successReason(source), - flagMetadata: found.metadata, - }; -} - -export function resolveNumberEntitlement( - snapshot: ChargebeeEntitlementsSnapshot, - flagKey: string, - defaultValue: number, - source: EntitlementCacheSource, -): EntitlementResolution { - const found = findEnabledEntitlement(snapshot, flagKey, defaultValue, source); - if (!("entitlement" in found)) return found; - - const rawValue = found.entitlement.value?.trim(); - if (rawValue?.toLowerCase() === "unlimited") { - return { - value: Number.POSITIVE_INFINITY, - variant: "unlimited", - reason: successReason(source), - flagMetadata: { ...found.metadata, unlimited: true }, - }; - } - - const value = - rawValue === undefined || rawValue === "" ? Number.NaN : Number(rawValue); - if (!Number.isFinite(value)) { - return errorResolution( - defaultValue, - "TYPE_MISMATCH", - `Chargebee feature ${flagKey} is not a numeric entitlement`, - ); - } - - return { - value, - variant: rawValue, - reason: successReason(source), - flagMetadata: found.metadata, - }; -} - -export function resolveObjectEntitlement( - snapshot: ChargebeeEntitlementsSnapshot, - flagKey: string, - defaultValue: T, - source: EntitlementCacheSource, -): EntitlementResolution { - const found = findEnabledEntitlement(snapshot, flagKey, defaultValue, source); - if (!("entitlement" in found)) return found; - - return { - value: found.entitlement as unknown as T, - variant: found.entitlement.value ?? "enabled", - reason: successReason(source), - flagMetadata: found.metadata, - }; -} +export { getTargetFromContext } from "./shared/context"; +export { + resolveBooleanEntitlement, + resolveNumberEntitlement, + resolveObjectEntitlement, + resolveStringEntitlement, +} from "./shared/evaluation"; +export { + createEntitlementsSnapshot, + isSnapshotExpired, + parseEntitlementsSnapshot, + parseSerializedEntitlementsSnapshot, + serializeEntitlementsSnapshot, +} from "./shared/snapshot"; +export { + CHARGEBEE_CONTEXT_KEYS, + type ChargebeeEntitlement, + type ChargebeeEntitlementsSnapshot, + type ChargebeeEvaluationMode, + type ChargebeeTarget, + type EntitlementCacheSource, + type EntitlementErrorCode, + type EntitlementResolution, + type EvaluationContextLike, +} from "./shared/types"; diff --git a/packages/openfeature/src/shared/context.ts b/packages/openfeature/src/shared/context.ts new file mode 100644 index 0000000..ff0616b --- /dev/null +++ b/packages/openfeature/src/shared/context.ts @@ -0,0 +1,30 @@ +import { + CHARGEBEE_CONTEXT_KEYS, + type ChargebeeEvaluationMode, + type ChargebeeTarget, + type EvaluationContextLike, +} from "./types"; + +export function getTargetFromContext( + context: EvaluationContextLike, + defaultMode: ChargebeeEvaluationMode = "customer", +): ChargebeeTarget { + const explicitMode = context[CHARGEBEE_CONTEXT_KEYS.evaluationMode]; + const mode = + explicitMode === "customer" || explicitMode === "subscription" + ? explicitMode + : defaultMode; + const key = + mode === "customer" + ? CHARGEBEE_CONTEXT_KEYS.customerId + : CHARGEBEE_CONTEXT_KEYS.subscriptionId; + const id = context[key]; + + if (typeof id !== "string" || id.length === 0) { + throw new Error(`Evaluation context requires a non-empty ${key}`); + } + + return mode === "customer" + ? { mode, customerId: id } + : { mode, subscriptionId: id }; +} diff --git a/packages/openfeature/src/shared/evaluation.ts b/packages/openfeature/src/shared/evaluation.ts new file mode 100644 index 0000000..f710d7d --- /dev/null +++ b/packages/openfeature/src/shared/evaluation.ts @@ -0,0 +1,198 @@ +import type { + ChargebeeEntitlement, + ChargebeeEntitlementsSnapshot, + EntitlementCacheSource, + EntitlementErrorCode, + EntitlementResolution, +} from "./types"; + +type EnabledEntitlement = { + entitlement: ChargebeeEntitlement; + metadata: Record; +}; + +function metadataFor( + entitlement: ChargebeeEntitlement, + source: EntitlementCacheSource, +): 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 } + : {}), + }; +} + +function error( + value: T, + errorCode: EntitlementErrorCode, + errorMessage: string, +): EntitlementResolution { + return { value, reason: "ERROR", errorCode, errorMessage }; +} + +function getEnabled( + snapshot: ChargebeeEntitlementsSnapshot, + flagKey: string, + defaultValue: T, + source: EntitlementCacheSource, +): EnabledEntitlement | EntitlementResolution { + const entitlement = snapshot.entitlements[flagKey]; + if (!entitlement) { + return error( + defaultValue, + "FLAG_NOT_FOUND", + `Chargebee feature ${flagKey} 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: EntitlementCacheSource) => + source === "api" ? "TARGETING_MATCH" : "CACHED"; + +export function resolveBooleanEntitlement( + snapshot: ChargebeeEntitlementsSnapshot, + flagKey: string, + defaultValue: boolean, + source: EntitlementCacheSource, +): EntitlementResolution { + const found = getEnabled(snapshot, flagKey, defaultValue, source); + if (!("entitlement" in found)) return found; + + const normalized = found.entitlement.value?.trim().toLowerCase(); + if ( + !normalized && + (found.entitlement.featureType === undefined || + found.entitlement.featureType === "switch") + ) { + return { + value: true, + variant: "enabled", + reason: reasonFor(source), + flagMetadata: found.metadata, + }; + } + if (!["true", "false", "available"].includes(normalized ?? "")) { + return error( + defaultValue, + "TYPE_MISMATCH", + `Chargebee feature ${flagKey} is not a boolean entitlement`, + ); + } + + const value = normalized === "true" || normalized === "available"; + return { + value, + variant: value ? "enabled" : "disabled", + reason: reasonFor(source), + flagMetadata: found.metadata, + }; +} + +export function resolveStringEntitlement( + snapshot: ChargebeeEntitlementsSnapshot, + flagKey: string, + defaultValue: string, + source: EntitlementCacheSource, +): EntitlementResolution { + const found = getEnabled(snapshot, flagKey, defaultValue, source); + if (!("entitlement" in found)) return found; + + const value = found.entitlement.value; + if (value === undefined) { + return error( + defaultValue, + "PARSE_ERROR", + `Chargebee feature ${flagKey} has no value`, + ); + } + + return { + value, + variant: value, + reason: reasonFor(source), + flagMetadata: found.metadata, + }; +} + +export function resolveNumberEntitlement( + snapshot: ChargebeeEntitlementsSnapshot, + flagKey: string, + defaultValue: number, + source: EntitlementCacheSource, +): EntitlementResolution { + const found = getEnabled(snapshot, flagKey, defaultValue, source); + if (!("entitlement" in found)) return found; + + const rawValue = found.entitlement.value?.trim(); + if (rawValue?.toLowerCase() === "unlimited") { + return { + value: Number.POSITIVE_INFINITY, + variant: "unlimited", + reason: reasonFor(source), + flagMetadata: { ...found.metadata, unlimited: true }, + }; + } + + const value = rawValue ? Number(rawValue) : Number.NaN; + if (!Number.isFinite(value)) { + return error( + defaultValue, + "TYPE_MISMATCH", + `Chargebee feature ${flagKey} is not a numeric entitlement`, + ); + } + + return { + value, + variant: rawValue, + reason: reasonFor(source), + flagMetadata: found.metadata, + }; +} + +export function resolveObjectEntitlement( + snapshot: ChargebeeEntitlementsSnapshot, + flagKey: string, + defaultValue: T, + source: EntitlementCacheSource, +): EntitlementResolution { + const found = getEnabled(snapshot, flagKey, defaultValue, source); + if (!("entitlement" in found)) return found; + + return { + value: found.entitlement as unknown as T, + variant: found.entitlement.value ?? "enabled", + reason: reasonFor(source), + flagMetadata: found.metadata, + }; +} diff --git a/packages/openfeature/src/shared/snapshot.ts b/packages/openfeature/src/shared/snapshot.ts new file mode 100644 index 0000000..dee3c10 --- /dev/null +++ b/packages/openfeature/src/shared/snapshot.ts @@ -0,0 +1,71 @@ +import { z } from "zod"; +import type { + ChargebeeEntitlement, + ChargebeeEntitlementsSnapshot, + ChargebeeEvaluationMode, +} from "./types"; + +const entitlementSchema = z.object({ + featureId: z.string().min(1), + value: z.string().optional(), + name: z.string().optional(), + featureName: z.string().optional(), + featureUnit: z.string().optional(), + featureType: z.string().optional(), + isEnabled: z.boolean(), + isOverridden: z.boolean().optional(), + expiresAt: z.number().int().optional(), +}); + +const snapshotSchema = z.object({ + schemaVersion: z.literal(1), + generatedAt: z.iso.datetime(), + expiresAt: z.iso.datetime(), + targetMode: z.enum(["customer", "subscription"]), + entitlements: z.record(z.string(), entitlementSchema), +}); + +export function parseEntitlementsSnapshot( + input: unknown, +): ChargebeeEntitlementsSnapshot { + return snapshotSchema.parse(input); +} + +export function serializeEntitlementsSnapshot( + snapshot: ChargebeeEntitlementsSnapshot, +): string { + return JSON.stringify(snapshot); +} + +export function parseSerializedEntitlementsSnapshot( + value: string, +): ChargebeeEntitlementsSnapshot { + return parseEntitlementsSnapshot(JSON.parse(value)); +} + +export function createEntitlementsSnapshot( + targetMode: ChargebeeEvaluationMode, + entitlements: Iterable, + ttlMs: number, + now = Date.now(), +): ChargebeeEntitlementsSnapshot { + return { + schemaVersion: 1, + generatedAt: new Date(now).toISOString(), + expiresAt: new Date(now + ttlMs).toISOString(), + targetMode, + 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/openfeature/src/shared/types.ts b/packages/openfeature/src/shared/types.ts new file mode 100644 index 0000000..9797866 --- /dev/null +++ b/packages/openfeature/src/shared/types.ts @@ -0,0 +1,56 @@ +export const CHARGEBEE_CONTEXT_KEYS = { + customerId: "chargebeeCustomerId", + subscriptionId: "chargebeeSubscriptionId", + evaluationMode: "chargebeeEvaluationMode", +} as const; + +export type ChargebeeEvaluationMode = "customer" | "subscription"; + +export type ChargebeeTarget = + | { mode: "customer"; customerId: string } + | { mode: "subscription"; subscriptionId: string }; + +export type EvaluationContextLike = Record & { + targetingKey?: string; +}; + +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; + targetMode: ChargebeeEvaluationMode; + entitlements: Record; +} + +export type EntitlementCacheSource = "api" | "memory" | "redis" | "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/openfeature/src/web.ts b/packages/openfeature/src/web.ts index 8908bf0..e8b6d53 100644 --- a/packages/openfeature/src/web.ts +++ b/packages/openfeature/src/web.ts @@ -1,221 +1,8 @@ -import { - type ErrorCode, - type EvaluationContext, - type JsonValue, - type Logger, - OpenFeatureEventEmitter, - type Provider, - ProviderEvents, - type ResolutionDetails, -} from "@openfeature/web-sdk"; -import { - type ChargebeeEntitlementsSnapshot, - type EntitlementResolution, - isSnapshotExpired, - parseEntitlementsSnapshot, - resolveBooleanEntitlement, - resolveNumberEntitlement, - resolveObjectEntitlement, - resolveStringEntitlement, -} from "./shared"; - -export interface ChargebeeEntitlementsWebProviderOptions { - relayUrl: string | URL; - fetchImplementation?: typeof fetch; - credentials?: RequestCredentials; - requestHeaders?: HeadersInit; -} - -export class ChargebeeEntitlementsWebProvider implements Provider { - readonly metadata = { name: "Chargebee Entitlements" } as const; - readonly runsOn = "client" as const; - readonly events = new OpenFeatureEventEmitter(); - - private readonly relayUrl: string; - private readonly fetchImplementation: typeof fetch; - private readonly credentials: RequestCredentials; - private readonly requestHeaders?: HeadersInit; - private snapshot?: ChargebeeEntitlementsSnapshot; - private staleEventEmitted = false; - private closed = false; - - constructor(options: ChargebeeEntitlementsWebProviderOptions) { - 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; - } - - async initialize(): Promise { - this.closed = false; - await this.loadSnapshot(false); - } - - async onContextChange( - _oldContext: EvaluationContext, - _newContext: EvaluationContext, - ): Promise { - // The relay identity may have changed with the application session. Never - // evaluate a new context against the previous subject's entitlements. - this.snapshot = undefined; - this.staleEventEmitted = false; - await this.refreshSnapshot(); - } - - async onClose(): Promise { - this.closed = true; - this.snapshot = undefined; - this.staleEventEmitted = false; - } - - async refreshSnapshot(): Promise { - await this.loadSnapshot(true); - } - - resolveBooleanEvaluation( - flagKey: string, - defaultValue: boolean, - _context: EvaluationContext, - _logger: Logger, - ): ResolutionDetails { - const snapshot = this.getUsableSnapshot(defaultValue); - if (!("entitlements" in snapshot)) return snapshot; - return this.toResolution( - resolveBooleanEntitlement(snapshot, flagKey, defaultValue, "relay"), - ); - } - - resolveStringEvaluation( - flagKey: string, - defaultValue: string, - _context: EvaluationContext, - _logger: Logger, - ): ResolutionDetails { - const snapshot = this.getUsableSnapshot(defaultValue); - if (!("entitlements" in snapshot)) return snapshot; - return this.toResolution( - resolveStringEntitlement(snapshot, flagKey, defaultValue, "relay"), - ); - } - - resolveNumberEvaluation( - flagKey: string, - defaultValue: number, - _context: EvaluationContext, - _logger: Logger, - ): ResolutionDetails { - const snapshot = this.getUsableSnapshot(defaultValue); - if (!("entitlements" in snapshot)) return snapshot; - return this.toResolution( - resolveNumberEntitlement(snapshot, flagKey, defaultValue, "relay"), - ); - } - - resolveObjectEvaluation( - flagKey: string, - defaultValue: T, - _context: EvaluationContext, - _logger: Logger, - ): ResolutionDetails { - const snapshot = this.getUsableSnapshot(defaultValue); - if (!("entitlements" in snapshot)) return snapshot; - return this.toResolution( - resolveObjectEntitlement(snapshot, flagKey, defaultValue, "relay"), - ); - } - - private getUsableSnapshot( - defaultValue: T, - ): ChargebeeEntitlementsSnapshot | ResolutionDetails { - if (!this.snapshot) { - return { - value: defaultValue, - reason: "ERROR", - errorCode: "PROVIDER_NOT_READY" as ErrorCode, - errorMessage: "Chargebee entitlement snapshot is not loaded", - }; - } - - if (isSnapshotExpired(this.snapshot)) { - if (!this.staleEventEmitted) { - this.events.emit(ProviderEvents.Stale, { - message: "Chargebee entitlement snapshot expired", - }); - this.staleEventEmitted = true; - } - return { - value: defaultValue, - reason: "STALE", - }; - } - - return this.snapshot; - } - - private toResolution( - resolution: EntitlementResolution, - ): ResolutionDetails { - return { - ...resolution, - errorCode: resolution.errorCode as ErrorCode | undefined, - }; - } - - private async loadSnapshot(emitChange: boolean): Promise { - if (this.closed) throw new Error("Chargebee web provider 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 previousFlags = new Set( - this.snapshot ? Object.keys(this.snapshot.entitlements) : [], - ); - const nextFlags = new Set(Object.keys(nextSnapshot.entitlements)); - const flagsChanged = [ - ...new Set([...previousFlags, ...nextFlags]), - ].filter( - (flag) => - JSON.stringify(this.snapshot?.entitlements[flag]) !== - JSON.stringify(nextSnapshot.entitlements[flag]), - ); - - this.snapshot = nextSnapshot; - this.staleEventEmitted = false; - if (emitChange && flagsChanged.length > 0) { - this.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged }); - } - } catch (error) { - if (emitChange) { - this.events.emit(ProviderEvents.Error, { - message: - error instanceof Error - ? error.message - : "Unable to refresh Chargebee entitlements", - }); - } - throw error; - } - } -} - export type { ChargebeeEntitlement, ChargebeeEntitlementsSnapshot, } from "./shared"; +export { + ChargebeeEntitlementsWebProvider, + type ChargebeeEntitlementsWebProviderOptions, +} from "./web/provider"; diff --git a/packages/openfeature/src/web/provider.ts b/packages/openfeature/src/web/provider.ts new file mode 100644 index 0000000..26da3a7 --- /dev/null +++ b/packages/openfeature/src/web/provider.ts @@ -0,0 +1,218 @@ +import { + type ErrorCode, + type EvaluationContext, + type JsonValue, + type Logger, + OpenFeatureEventEmitter, + type Provider, + ProviderEvents, + type ResolutionDetails, +} from "@openfeature/web-sdk"; +import { + type ChargebeeEntitlementsSnapshot, + type EntitlementResolution, + isSnapshotExpired, + parseEntitlementsSnapshot, + resolveBooleanEntitlement, + resolveNumberEntitlement, + resolveObjectEntitlement, + resolveStringEntitlement, +} from "../shared"; + +export interface ChargebeeEntitlementsWebProviderOptions { + relayUrl: string | URL; + fetchImplementation?: typeof fetch; + credentials?: RequestCredentials; + requestHeaders?: HeadersInit; +} + +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]), + ); + +export class ChargebeeEntitlementsWebProvider implements Provider { + readonly metadata = { name: "Chargebee Entitlements" } as const; + readonly runsOn = "client" as const; + readonly events = new OpenFeatureEventEmitter(); + + private readonly relayUrl: string; + private readonly fetchImplementation: typeof fetch; + private readonly credentials: RequestCredentials; + private readonly requestHeaders?: HeadersInit; + private snapshot?: ChargebeeEntitlementsSnapshot; + private staleEventEmitted = false; + private closed = false; + + constructor(options: ChargebeeEntitlementsWebProviderOptions) { + 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; + } + + async initialize(): Promise { + this.closed = false; + await this.loadSnapshot(false); + } + + async onContextChange( + _oldContext: EvaluationContext, + _newContext: EvaluationContext, + ): Promise { + // A new session may represent a different billing subject. + this.snapshot = undefined; + this.staleEventEmitted = false; + await this.refreshSnapshot(); + } + + async onClose(): Promise { + this.closed = true; + this.snapshot = undefined; + this.staleEventEmitted = false; + } + + refreshSnapshot(): Promise { + return this.loadSnapshot(true); + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + _context: EvaluationContext, + _logger: Logger, + ): ResolutionDetails { + return this.evaluate(defaultValue, (snapshot) => + resolveBooleanEntitlement(snapshot, flagKey, defaultValue, "relay"), + ); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + _context: EvaluationContext, + _logger: Logger, + ): ResolutionDetails { + return this.evaluate(defaultValue, (snapshot) => + resolveStringEntitlement(snapshot, flagKey, defaultValue, "relay"), + ); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + _context: EvaluationContext, + _logger: Logger, + ): ResolutionDetails { + return this.evaluate(defaultValue, (snapshot) => + resolveNumberEntitlement(snapshot, flagKey, defaultValue, "relay"), + ); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + _context: EvaluationContext, + _logger: Logger, + ): ResolutionDetails { + return this.evaluate(defaultValue, (snapshot) => + resolveObjectEntitlement(snapshot, flagKey, defaultValue, "relay"), + ); + } + + private evaluate( + defaultValue: T, + resolve: ( + snapshot: ChargebeeEntitlementsSnapshot, + ) => EntitlementResolution, + ): ResolutionDetails { + const snapshot = this.getUsableSnapshot(defaultValue); + return "entitlements" in snapshot + ? this.toResolution(resolve(snapshot)) + : snapshot; + } + + private getUsableSnapshot( + defaultValue: T, + ): ChargebeeEntitlementsSnapshot | ResolutionDetails { + if (!this.snapshot) { + return { + value: defaultValue, + reason: "ERROR", + errorCode: "PROVIDER_NOT_READY" as ErrorCode, + errorMessage: "Chargebee entitlement snapshot is not loaded", + }; + } + + if (!isSnapshotExpired(this.snapshot)) return this.snapshot; + + if (!this.staleEventEmitted) { + this.events.emit(ProviderEvents.Stale, { + message: "Chargebee entitlement snapshot expired", + }); + this.staleEventEmitted = true; + } + return { value: defaultValue, reason: "STALE" }; + } + + private toResolution( + resolution: EntitlementResolution, + ): ResolutionDetails { + return { + ...resolution, + errorCode: resolution.errorCode as ErrorCode | undefined, + }; + } + + private async loadSnapshot(emitChange: boolean): Promise { + if (this.closed) throw new Error("Chargebee web provider 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.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged }); + } + } catch (error) { + if (emitChange) { + this.events.emit(ProviderEvents.Error, { + message: + error instanceof Error + ? error.message + : "Unable to refresh Chargebee entitlements", + }); + } + throw error; + } + } +} diff --git a/packages/openfeature/test/cache.test.ts b/packages/openfeature/test/cache.test.ts index 03cd1b7..cacbda5 100644 --- a/packages/openfeature/test/cache.test.ts +++ b/packages/openfeature/test/cache.test.ts @@ -1,7 +1,7 @@ import { createRedisEntitlementsCache, - LayeredEntitlementsCache, MemoryEntitlementsCache, + TieredEntitlementsCache, } from "../src/cache"; import { createEntitlementsSnapshot } from "../src/shared"; @@ -58,7 +58,7 @@ describe("Redis entitlement cache", () => { }); }); -describe("LayeredEntitlementsCache", () => { +describe("TieredEntitlementsCache", () => { it("hydrates memory from Redis and falls back when Redis fails", async () => { const snapshot = makeSnapshot(); const memory = new MemoryEntitlementsCache(); @@ -68,7 +68,7 @@ describe("LayeredEntitlementsCache", () => { delete: vi.fn(async () => undefined), }; const onError = vi.fn(); - const cache = new LayeredEntitlementsCache({ memory, redis, onError }); + const cache = new TieredEntitlementsCache({ memory, redis, onError }); expect(await cache.get("target")).toMatchObject({ source: "redis", snapshot }); expect(await cache.get("target")).toMatchObject({ source: "memory", snapshot }); @@ -87,7 +87,7 @@ describe("LayeredEntitlementsCache", () => { set: vi.fn(async () => undefined), delete: vi.fn(async () => undefined), }; - const cache = new LayeredEntitlementsCache({ memory, redis }); + const cache = new TieredEntitlementsCache({ memory, redis }); await cache.set("target", snapshot); await cache.delete("target"); From d1550ec54e7909f74f4c22aaef2d4a5fdc9808cf Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 07:09:33 +0000 Subject: [PATCH 03/22] fix: apply CodeRabbit auto-fixes Fixed 2 file(s) based on 2 unresolved review comments. Co-authored-by: CodeRabbit --- packages/openfeature/src/cache/tiered.ts | 14 +++++++++----- packages/openfeature/src/web/provider.ts | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/openfeature/src/cache/tiered.ts b/packages/openfeature/src/cache/tiered.ts index e6e104e..fdde8c6 100644 --- a/packages/openfeature/src/cache/tiered.ts +++ b/packages/openfeature/src/cache/tiered.ts @@ -41,11 +41,15 @@ export class TieredEntitlementsCache { try { const snapshot = await this.redis.get(key); if (!snapshot) return undefined; - await this.memory.set( - key, - snapshot, - Math.min(this.memoryTtlMs, Date.parse(snapshot.expiresAt) - Date.now()), - ); + try { + await this.memory.set( + key, + snapshot, + Math.min(this.memoryTtlMs, Date.parse(snapshot.expiresAt) - Date.now()), + ); + } catch (memoryError) { + this.onError?.(memoryError, "memory"); + } return { snapshot, source: "redis" }; } catch (error) { this.onError?.(error, "redis"); diff --git a/packages/openfeature/src/web/provider.ts b/packages/openfeature/src/web/provider.ts index 26da3a7..97b0cdf 100644 --- a/packages/openfeature/src/web/provider.ts +++ b/packages/openfeature/src/web/provider.ts @@ -53,6 +53,7 @@ export class ChargebeeEntitlementsWebProvider implements Provider { private snapshot?: ChargebeeEntitlementsSnapshot; private staleEventEmitted = false; private closed = false; + private refreshInProgress = false; constructor(options: ChargebeeEntitlementsWebProviderOptions) { if (!options?.relayUrl) throw new Error("relayUrl is required"); @@ -77,6 +78,7 @@ export class ChargebeeEntitlementsWebProvider implements Provider { // A new session may represent a different billing subject. this.snapshot = undefined; this.staleEventEmitted = false; + this.refreshInProgress = false; await this.refreshSnapshot(); } @@ -84,6 +86,7 @@ export class ChargebeeEntitlementsWebProvider implements Provider { this.closed = true; this.snapshot = undefined; this.staleEventEmitted = false; + this.refreshInProgress = false; } refreshSnapshot(): Promise { @@ -166,6 +169,18 @@ export class ChargebeeEntitlementsWebProvider implements Provider { }); this.staleEventEmitted = true; } + + if (!this.refreshInProgress) { + this.refreshInProgress = true; + this.refreshSnapshot() + .catch(() => { + // Errors are already handled in loadSnapshot + }) + .finally(() => { + this.refreshInProgress = false; + }); + } + return { value: defaultValue, reason: "STALE" }; } From fd25a99bae4ae43b08e1351a83cdc262d6fd755c Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Tue, 11 Aug 2026 17:20:00 +1000 Subject: [PATCH 04/22] v0.1.0-alpha.1 --- packages/openfeature/package.json | 2 +- packages/openfeature/src/cache.ts | 2 +- .../src/cache/{tiered.ts => tiered-cache.ts} | 11 +++++++---- 3 files changed, 9 insertions(+), 6 deletions(-) rename packages/openfeature/src/cache/{tiered.ts => tiered-cache.ts} (89%) diff --git a/packages/openfeature/package.json b/packages/openfeature/package.json index dad1528..1a57988 100644 --- a/packages/openfeature/package.json +++ b/packages/openfeature/package.json @@ -1,7 +1,7 @@ { "name": "@chargebee/openfeature", "author": "DX Chargebee", - "version": "0.1.0", + "version": "0.1.0-alpha.1", "type": "module", "description": "OpenFeature provider for Chargebee", "license": "MIT", diff --git a/packages/openfeature/src/cache.ts b/packages/openfeature/src/cache.ts index 18ec85a..0bf761e 100644 --- a/packages/openfeature/src/cache.ts +++ b/packages/openfeature/src/cache.ts @@ -7,7 +7,7 @@ export { createRedisEntitlementsCache } from "./cache/redis"; export { TieredEntitlementsCache, type TieredEntitlementsCacheOptions, -} from "./cache/tiered"; +} from "./cache/tiered-cache"; export type { CacheLookup, EntitlementsCache, diff --git a/packages/openfeature/src/cache/tiered.ts b/packages/openfeature/src/cache/tiered-cache.ts similarity index 89% rename from packages/openfeature/src/cache/tiered.ts rename to packages/openfeature/src/cache/tiered-cache.ts index fdde8c6..941559c 100644 --- a/packages/openfeature/src/cache/tiered.ts +++ b/packages/openfeature/src/cache/tiered-cache.ts @@ -1,6 +1,6 @@ -import type { ChargebeeEntitlementsSnapshot } from "../shared"; -import { MemoryEntitlementsCache } from "./memory"; -import type { CacheLookup, EntitlementsCache } from "./types"; +import type { ChargebeeEntitlementsSnapshot } from "../shared.js"; +import { MemoryEntitlementsCache } from "./memory.js"; +import type { CacheLookup, EntitlementsCache } from "./types.js"; export interface TieredEntitlementsCacheOptions { memory?: EntitlementsCache; @@ -45,7 +45,10 @@ export class TieredEntitlementsCache { await this.memory.set( key, snapshot, - Math.min(this.memoryTtlMs, Date.parse(snapshot.expiresAt) - Date.now()), + Math.min( + this.memoryTtlMs, + Date.parse(snapshot.expiresAt) - Date.now(), + ), ); } catch (memoryError) { this.onError?.(memoryError, "memory"); From 206d329889790ab61d670471660aeaef203919d8 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Tue, 11 Aug 2026 17:35:53 +1000 Subject: [PATCH 05/22] upgrade to use pnpm v11 --- package.json | 2 +- pnpm-workspace.yaml | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 1aba9a8..cbf32b8 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "node": ">=20", "pnpm": ">=10" }, - "packageManager": "pnpm@10.18.1", + "packageManager": "pnpm@11.21.0", "devDependencies": { "@biomejs/biome": "^2.2.2", "@sindresorhus/tsconfig": "^7.0.0", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2c9fb92..36621e1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,11 +1,15 @@ 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/openfeature': link:packages/openfeature +allowBuilds: + '@biomejs/biome': true + '@prisma/engines': false + better-sqlite3: false + core-js: true + esbuild: true + prisma: false + sharp: false From 1011a98a0079984d6814a30a35fbfb9c01c9fa82 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Tue, 11 Aug 2026 17:36:19 +1000 Subject: [PATCH 06/22] check if web connection is closed before refreshing --- packages/openfeature/src/web/provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openfeature/src/web/provider.ts b/packages/openfeature/src/web/provider.ts index 97b0cdf..9b77f64 100644 --- a/packages/openfeature/src/web/provider.ts +++ b/packages/openfeature/src/web/provider.ts @@ -170,7 +170,7 @@ export class ChargebeeEntitlementsWebProvider implements Provider { this.staleEventEmitted = true; } - if (!this.refreshInProgress) { + if (!this.refreshInProgress && !this.closed) { this.refreshInProgress = true; this.refreshSnapshot() .catch(() => { From b43d86fd25d870727c863cc6e3ccd284dce30072 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Tue, 11 Aug 2026 17:39:22 +1000 Subject: [PATCH 07/22] remove node 20 and add 26 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 3423989355be7e35a3bc01f3e74409f7b6328014 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Tue, 11 Aug 2026 17:42:58 +1000 Subject: [PATCH 08/22] bump engines in package.json [skip ci] --- package.json | 4 ++-- packages/openfeature/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index cbf32b8..ccb6dd6 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "private": true, "license": "MIT", "engines": { - "node": ">=20", - "pnpm": ">=10" + "node": ">=22", + "pnpm": ">=11" }, "packageManager": "pnpm@11.21.0", "devDependencies": { diff --git a/packages/openfeature/package.json b/packages/openfeature/package.json index 1a57988..38e96db 100644 --- a/packages/openfeature/package.json +++ b/packages/openfeature/package.json @@ -19,7 +19,7 @@ "nextjs" ], "engines": { - "node": ">=20" + "node": ">=22" }, "main": "dist/index.mjs", "module": "dist/index.mjs", From 79a1b9b3abdb80e55d534d33d9ac1c902ccd0823 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Wed, 12 Aug 2026 17:29:47 +1000 Subject: [PATCH 09/22] major rewrite --- packages/openfeature/CHANGELOG.md | 53 +++ packages/openfeature/README.md | 149 +++++-- packages/openfeature/package.json | 11 +- packages/openfeature/src/cache.ts | 15 - packages/openfeature/src/cache/index.ts | 13 + packages/openfeature/src/cache/memory.ts | 104 +++-- packages/openfeature/src/cache/redis.ts | 39 +- .../openfeature/src/cache/tiered-cache.ts | 103 ----- packages/openfeature/src/cache/types.ts | 25 +- packages/openfeature/src/index.ts | 2 +- packages/openfeature/src/nextjs.ts | 57 +-- .../src/{server.ts => server/index.ts} | 17 +- packages/openfeature/src/server/provider.ts | 420 ++++++++++++++---- packages/openfeature/src/server/relay.ts | 20 +- packages/openfeature/src/shared/evaluation.ts | 42 +- .../src/{shared.ts => shared/index.ts} | 12 +- packages/openfeature/src/shared/types.ts | 6 +- packages/openfeature/src/web.ts | 8 - packages/openfeature/src/web/index.ts | 4 + packages/openfeature/src/web/provider.ts | 26 +- packages/openfeature/test/cache.test.ts | 110 +++-- packages/openfeature/test/server.test.ts | 268 ++++++++++- packages/openfeature/test/shared.test.ts | 4 +- packages/openfeature/tsdown.config.ts | 15 +- pnpm-lock.yaml | 114 +++-- 25 files changed, 1110 insertions(+), 527 deletions(-) delete mode 100644 packages/openfeature/src/cache.ts create mode 100644 packages/openfeature/src/cache/index.ts delete mode 100644 packages/openfeature/src/cache/tiered-cache.ts rename packages/openfeature/src/{server.ts => server/index.ts} (51%) rename packages/openfeature/src/{shared.ts => shared/index.ts} (74%) delete mode 100644 packages/openfeature/src/web.ts create mode 100644 packages/openfeature/src/web/index.ts diff --git a/packages/openfeature/CHANGELOG.md b/packages/openfeature/CHANGELOG.md index 924add2..e620e5f 100644 --- a/packages/openfeature/CHANGELOG.md +++ b/packages/openfeature/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## Unreleased + +- Replaced the tiered cache with a `cache` slot in front of a durable `store` + 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, + and the new `evictCachedSnapshot` drops the cached copy without fetching, 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`, + `ChargebeeEntitlementsSnapshot`, `ChargebeeEvaluationMode`, + `CHARGEBEE_CONTEXT_KEYS`, 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. +- `@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. diff --git a/packages/openfeature/README.md b/packages/openfeature/README.md index fd91d1f..11fd63d 100644 --- a/packages/openfeature/README.md +++ b/packages/openfeature/README.md @@ -2,8 +2,8 @@ `@chargebee/openfeature` exposes Chargebee Entitlements through the OpenFeature server and web SDKs. It supports customer-level consolidated entitlements, -subscription-level entitlements, tiered memory/Redis caching, and an -authenticated browser relay for Next.js 16. +subscription-level entitlements, a shared cache in front of a durable snapshot +store, and an authenticated browser relay for Next.js 16. ## Install @@ -83,73 +83,130 @@ Disabled or expired entitlements return the caller's default with the `DISABLED` reason. Missing features return `FLAG_NOT_FOUND`; invalid value types return `TYPE_MISMATCH`. -## Caching +## Snapshot resolution The provider 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. -The default L1 cache is a bounded in-memory LRU with a 30-second TTL. Add a -shared Redis L2 cache for multi-instance or serverless deployments: +A snapshot is resolved in three steps: + +1. `cache` — a shared, fast store such as Redis. It absorbs the many + entitlement checks a single authenticated request makes. +2. `store` — 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, - TieredEntitlementsCache, -} from "@chargebee/openfeature/cache"; - -const redisCache = createRedisEntitlementsCache({ - get: (key) => redis.get(key), - set: async (key, value, ttlMs) => { - await redis.set(key, value, { PX: ttlMs }); - }, - delete: async (key) => { - await redis.del(key); - }, -}); +import { createRedisEntitlementsCache } from "@chargebee/openfeature/cache"; +import Redis from "ioredis"; -const cache = new TieredEntitlementsCache({ - redis: redisCache, - memoryTtlMs: 30_000, - redisTtlMs: 5 * 60_000, -}); +const redis = new Redis(process.env.REDIS_URL); +const cache = createRedisEntitlementsCache(redis, { ttlMs: 60_000 }); export const entitlementsProvider = new ChargebeeEntitlementsProvider({ chargebeeClient: chargebee, cache, + store: postgresSnapshotStore, + snapshotTtlMs: 24 * 60 * 60_000, cacheNamespace: "my-app:chargebee:entitlements:v1", }); ``` -The adapter is client-agnostic. Adapt other Redis clients as follows: +`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 -// ioredis -set: async (key, value, ttlMs) => { - await ioRedis.set(key, value, "PX", ttlMs); -} +import type { EntitlementsStorage } from "@chargebee/openfeature/cache"; +import { parseSerializedEntitlementsSnapshot, serializeEntitlementsSnapshot } from "@chargebee/openfeature"; -// @upstash/redis -set: async (key, value, ttlMs) => { - await upstash.set(key, value, { px: ttlMs }); -} +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 provider's `cacheTtlMs` overrides it per write +if you would rather configure expiry alongside the provider. 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 +provider 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 +`refreshBackoffMs`. + +Refresh the snapshot after processing relevant Chargebee webhooks, and remove +it when the target no longer exists: + +```ts +await entitlementsProvider.refreshSnapshot({ mode: "subscription", subscriptionId }); +await entitlementsProvider.deleteSnapshot({ mode: "customer", customerId }); ``` -Lookup order is memory, Redis, then Chargebee. Redis failures degrade to the -memory cache and Chargebee API. Concurrent misses in one process are -deduplicated. +`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 +`evictCachedSnapshot` on its own when you want that fall-through without +fetching, and `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. -Invalidate both cache layers after processing relevant Chargebee webhooks: +### 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 provider 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 -await entitlementsProvider.invalidate({ - mode: "customer", - customerId, +const provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: chargebee, + cache, + store: postgresSnapshotStore, + refreshOnMiss: "background", + onSnapshotRefreshed: ({ target, trigger }) => { + if (trigger === "request") notifySubscriptionReady(target); + }, + onError: (error, { operation, target }) => { + logger.error({ error, operation, target }, "entitlement snapshot"); + }, }); ``` -Other application instances can retain an L1 value for at most the configured -short memory TTL after Redis invalidation. +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. ## Next.js 16 browser relay @@ -161,14 +218,14 @@ Create an App Router route: ```ts // app/api/entitlements/route.ts -import { createChargebeeEntitlementsHandler } from "@chargebee/openfeature/nextjs"; +import { createEntitlementsRelayHandler } from "@chargebee/openfeature/nextjs"; import { entitlementsProvider } from "@/lib/entitlements"; import { getSession } from "@/lib/auth"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -export const GET = createChargebeeEntitlementsHandler({ +export const GET = createEntitlementsRelayHandler({ provider: entitlementsProvider, resolveContext: async (request) => { const session = await getSession(request); @@ -224,8 +281,8 @@ independent snapshots for multiple subscriptions. - Customer mode uses `consolidate_entitlements=true` by default. - Chargebee pagination is followed with a page size of 100. -- The default server cache fails closed after expiry; stale grants are not - served. +- Without a `store`, 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 `OpenFeature.close()` during long-lived process shutdown. diff --git a/packages/openfeature/package.json b/packages/openfeature/package.json index 38e96db..c3b49d0 100644 --- a/packages/openfeature/package.json +++ b/packages/openfeature/package.json @@ -31,17 +31,17 @@ "default": "./dist/index.mjs" }, "./cache": { - "dev-source": "./src/cache.ts", + "dev-source": "./src/cache/index.ts", "types": "./dist/cache.d.mts", "default": "./dist/cache.mjs" }, "./server": { - "dev-source": "./src/server.ts", + "dev-source": "./src/server/index.ts", "types": "./dist/server.d.mts", "default": "./dist/server.mjs" }, "./web": { - "dev-source": "./src/web.ts", + "dev-source": "./src/web/index.ts", "types": "./dist/web.d.mts", "default": "./dist/web.mjs" }, @@ -100,6 +100,7 @@ "@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", @@ -110,6 +111,7 @@ "@openfeature/server-sdk": "^1.23.0", "@openfeature/web-sdk": "^1.10.0", "chargebee": "^3.30.0", + "ioredis": "^6.0.0", "next": "^16.0.0" }, "peerDependenciesMeta": { @@ -122,6 +124,9 @@ "chargebee": { "optional": true }, + "ioredis": { + "optional": true + }, "next": { "optional": true } diff --git a/packages/openfeature/src/cache.ts b/packages/openfeature/src/cache.ts deleted file mode 100644 index 0bf761e..0000000 --- a/packages/openfeature/src/cache.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { createEntitlementsCacheKey } from "./cache/key"; -export { - MemoryEntitlementsCache, - type MemoryEntitlementsCacheOptions, -} from "./cache/memory"; -export { createRedisEntitlementsCache } from "./cache/redis"; -export { - TieredEntitlementsCache, - type TieredEntitlementsCacheOptions, -} from "./cache/tiered-cache"; -export type { - CacheLookup, - EntitlementsCache, - RedisCacheCommands, -} from "./cache/types"; diff --git a/packages/openfeature/src/cache/index.ts b/packages/openfeature/src/cache/index.ts new file mode 100644 index 0000000..cef1844 --- /dev/null +++ b/packages/openfeature/src/cache/index.ts @@ -0,0 +1,13 @@ +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/openfeature/src/cache/memory.ts b/packages/openfeature/src/cache/memory.ts index 2727a01..86a90fb 100644 --- a/packages/openfeature/src/cache/memory.ts +++ b/packages/openfeature/src/cache/memory.ts @@ -1,11 +1,10 @@ -import { - type ChargebeeEntitlementsSnapshot, - isSnapshotExpired, -} from "../shared"; -import type { EntitlementsCache } from "./types"; +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; } @@ -14,61 +13,58 @@ interface MemoryCacheEntry { expiresAt: number; } -export class MemoryEntitlementsCache implements EntitlementsCache { - private readonly entries = new Map(); - private readonly maxEntries: number; - private readonly now: () => number; - - constructor(options: MemoryEntitlementsCacheOptions = {}) { - this.maxEntries = options.maxEntries ?? 500; - this.now = options.now ?? Date.now; - if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) { - throw new Error("maxEntries must be a positive integer"); - } +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"); } - async get(key: string): Promise { - const entry = this.entries.get(key); - if (!entry) return undefined; + const entries = new Map(); - if ( - entry.expiresAt <= this.now() || - isSnapshotExpired(entry.snapshot, this.now()) - ) { - this.entries.delete(key); - return undefined; - } + return { + async get(key) { + const entry = entries.get(key); + if (!entry) return undefined; - // Moving a hit to the end makes Map insertion order an LRU list. - this.entries.delete(key); - this.entries.set(key, entry); - return entry.snapshot; - } + if (entry.expiresAt <= now()) { + entries.delete(key); + return undefined; + } - async set( - key: string, - snapshot: ChargebeeEntitlementsSnapshot, - ttlMs: number, - ): Promise { - if (ttlMs <= 0) return; - this.entries.delete(key); - this.entries.set(key, { - snapshot, - expiresAt: this.now() + ttlMs, - }); + // Moving a hit to the end makes Map insertion order an LRU list. + entries.delete(key); + entries.set(key, entry); + return entry.snapshot; + }, - while (this.entries.size > this.maxEntries) { - const oldestKey = this.entries.keys().next().value; - if (oldestKey === undefined) return; - this.entries.delete(oldestKey); - } - } + async set(key, snapshot, entryTtlMs = ttlMs) { + if (entryTtlMs <= 0) return; + entries.delete(key); + entries.set(key, { + snapshot, + expiresAt: now() + entryTtlMs, + }); - async delete(key: string): Promise { - this.entries.delete(key); - } + while (entries.size > maxEntries) { + const oldestKey = entries.keys().next().value; + if (oldestKey === undefined) return; + entries.delete(oldestKey); + } + }, - async clear(): Promise { - this.entries.clear(); - } + async delete(key) { + entries.delete(key); + }, + + async clear() { + entries.clear(); + }, + }; } diff --git a/packages/openfeature/src/cache/redis.ts b/packages/openfeature/src/cache/redis.ts index dedbcfd..1080bfc 100644 --- a/packages/openfeature/src/cache/redis.ts +++ b/packages/openfeature/src/cache/redis.ts @@ -1,31 +1,44 @@ import { - isSnapshotExpired, parseSerializedEntitlementsSnapshot, serializeEntitlementsSnapshot, } from "../shared"; -import type { EntitlementsCache, RedisCacheCommands } from "./types"; +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( - commands: RedisCacheCommands, -): EntitlementsCache { + 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 commands.get(key); + const value = await client.get(key); if (value == null) return undefined; try { - const snapshot = parseSerializedEntitlementsSnapshot(value); - if (!isSnapshotExpired(snapshot)) return snapshot; + return parseSerializedEntitlementsSnapshot(value); } catch { // Corrupt or old cache values are treated as misses. + await client.del(key); + return undefined; } - - await commands.delete(key); - return undefined; }, - async set(key, value, ttlMs) { - await commands.set(key, serializeEntitlementsSnapshot(value), ttlMs); + async set(key, value, ttlMs = defaultTtlMs) { + await client.set(key, serializeEntitlementsSnapshot(value), "PX", ttlMs); + }, + async delete(key) { + await client.del(key); }, - delete: (key) => commands.delete(key), }; } diff --git a/packages/openfeature/src/cache/tiered-cache.ts b/packages/openfeature/src/cache/tiered-cache.ts deleted file mode 100644 index 941559c..0000000 --- a/packages/openfeature/src/cache/tiered-cache.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { ChargebeeEntitlementsSnapshot } from "../shared.js"; -import { MemoryEntitlementsCache } from "./memory.js"; -import type { CacheLookup, EntitlementsCache } from "./types.js"; - -export interface TieredEntitlementsCacheOptions { - memory?: EntitlementsCache; - redis?: EntitlementsCache; - memoryTtlMs?: number; - redisTtlMs?: number; - onError?: (error: unknown, tier: "memory" | "redis") => void; -} - -export class TieredEntitlementsCache { - private readonly memory: EntitlementsCache; - private readonly redis?: EntitlementsCache; - private readonly memoryTtlMs: number; - private readonly redisTtlMs: number; - private readonly onError?: (error: unknown, tier: "memory" | "redis") => void; - - constructor(options: TieredEntitlementsCacheOptions = {}) { - this.memory = options.memory ?? new MemoryEntitlementsCache(); - this.redis = options.redis; - this.memoryTtlMs = options.memoryTtlMs ?? 30_000; - this.redisTtlMs = options.redisTtlMs ?? 300_000; - this.onError = options.onError; - } - - get snapshotTtlMs(): number { - return this.redis ? this.redisTtlMs : this.memoryTtlMs; - } - - async get(key: string): Promise { - try { - const snapshot = await this.memory.get(key); - if (snapshot) return { snapshot, source: "memory" }; - } catch (error) { - this.onError?.(error, "memory"); - } - - if (!this.redis) return undefined; - try { - const snapshot = await this.redis.get(key); - if (!snapshot) return undefined; - try { - await this.memory.set( - key, - snapshot, - Math.min( - this.memoryTtlMs, - Date.parse(snapshot.expiresAt) - Date.now(), - ), - ); - } catch (memoryError) { - this.onError?.(memoryError, "memory"); - } - return { snapshot, source: "redis" }; - } catch (error) { - this.onError?.(error, "redis"); - return undefined; - } - } - - async set( - key: string, - snapshot: ChargebeeEntitlementsSnapshot, - ): Promise { - if (this.redis) { - try { - await this.redis.set(key, snapshot, this.redisTtlMs); - } catch (error) { - this.onError?.(error, "redis"); - } - } - - try { - await this.memory.set(key, snapshot, this.memoryTtlMs); - } catch (error) { - this.onError?.(error, "memory"); - } - } - - async delete(key: string): Promise { - const remove = async ( - cache: EntitlementsCache, - tier: "memory" | "redis", - ) => { - try { - await cache.delete(key); - } catch (error) { - this.onError?.(error, tier); - } - }; - - await Promise.all([ - remove(this.memory, "memory"), - ...(this.redis ? [remove(this.redis, "redis")] : []), - ]); - } - - async clearMemory(): Promise { - await this.memory.clear?.(); - } -} diff --git a/packages/openfeature/src/cache/types.ts b/packages/openfeature/src/cache/types.ts index e6c4260..8acbcd4 100644 --- a/packages/openfeature/src/cache/types.ts +++ b/packages/openfeature/src/cache/types.ts @@ -1,23 +1,24 @@ +import type Redis from "ioredis"; import type { ChargebeeEntitlementsSnapshot } from "../shared"; -export interface EntitlementsCache { +/** + * 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, + ttlMs?: number, ): Promise; delete(key: string): Promise; clear?(): Promise; } -export interface CacheLookup { - snapshot: ChargebeeEntitlementsSnapshot; - source: "memory" | "redis"; -} - -export interface RedisCacheCommands { - get(key: string): Promise; - set(key: string, value: string, ttlMs: number): Promise; - delete(key: string): Promise; -} +/** The subset of an ioredis `Redis` client that `createRedisEntitlementsCache` needs. */ +export type RedisEntitlementsCacheClient = Pick; diff --git a/packages/openfeature/src/index.ts b/packages/openfeature/src/index.ts index 14c678e..83a947e 100644 --- a/packages/openfeature/src/index.ts +++ b/packages/openfeature/src/index.ts @@ -3,8 +3,8 @@ export type { ChargebeeEntitlementsSnapshot, ChargebeeEvaluationMode, ChargebeeTarget, - EntitlementCacheSource, EvaluationContextLike, + SnapshotSource, } from "./shared"; export { CHARGEBEE_CONTEXT_KEYS, diff --git a/packages/openfeature/src/nextjs.ts b/packages/openfeature/src/nextjs.ts index 4b499a8..912b885 100644 --- a/packages/openfeature/src/nextjs.ts +++ b/packages/openfeature/src/nextjs.ts @@ -1,51 +1,22 @@ import "server-only"; -import type { EvaluationContext } from "@openfeature/server-sdk"; import type { NextRequest } from "next/server"; import { - type ChargebeeEntitlementsProvider, - createEntitlementsRelayHandler, + type CreateEntitlementsRelayHandlerOptions, + createEntitlementsRelayHandler as createRelayHandler, + type EntitlementsRelayHandler, } from "./server"; -export interface CreateChargebeeEntitlementsHandlerOptions { - provider: ChargebeeEntitlementsProvider; - resolveContext: ( - request: NextRequest, - ) => EvaluationContext | null | Promise; - snapshotTtlMs?: number; - onError?: ( - error: unknown, - request: NextRequest, - ) => Response | Promise; +/** + * Builds an App Router `GET` handler that resolves billing identity on the + * server and returns a sanitized entitlement snapshot for the browser + * provider. This is the Next.js `NextRequest` instantiation of + * `createEntitlementsRelayHandler` from `@chargebee/openfeature/server`. + */ +export function createEntitlementsRelayHandler( + options: CreateEntitlementsRelayHandlerOptions, +): EntitlementsRelayHandler { + return createRelayHandler(options); } -export type ChargebeeEntitlementsRouteHandler = ( - request: NextRequest, -) => Promise; - -export function createChargebeeEntitlementsHandler( - options: CreateChargebeeEntitlementsHandlerOptions, -): ChargebeeEntitlementsRouteHandler { - const onError = options.onError; - const handler = createEntitlementsRelayHandler({ - provider: options.provider, - resolveContext: (request) => options.resolveContext(request as NextRequest), - snapshotTtlMs: options.snapshotTtlMs, - onError: onError - ? (error, request) => onError(error, request as NextRequest) - : undefined, - }); - - return (request: NextRequest) => handler(request); -} - -export function createChargebeeEntitlementsRoute( - options: CreateChargebeeEntitlementsHandlerOptions, -): { GET: ChargebeeEntitlementsRouteHandler } { - return { GET: createChargebeeEntitlementsHandler(options) }; -} - -export type { - ChargebeeEntitlementsProvider, - ChargebeeEntitlementsProviderOptions, -} from "./server"; +export type { CreateEntitlementsRelayHandlerOptions, EntitlementsRelayHandler }; diff --git a/packages/openfeature/src/server.ts b/packages/openfeature/src/server/index.ts similarity index 51% rename from packages/openfeature/src/server.ts rename to packages/openfeature/src/server/index.ts index 2361017..d8e7f72 100644 --- a/packages/openfeature/src/server.ts +++ b/packages/openfeature/src/server/index.ts @@ -3,15 +3,14 @@ export { ChargebeeEntitlementsProvider, type ChargebeeEntitlementsProviderOptions, type EntitlementsSnapshotResult, -} from "./server/provider"; + type RefreshOnMiss, + type SnapshotErrorInfo, + type SnapshotOperation, + SnapshotPendingError, + type SnapshotRefreshedEvent, +} from "./provider"; export { type CreateEntitlementsRelayHandlerOptions, createEntitlementsRelayHandler, -} from "./server/relay"; -export { - CHARGEBEE_CONTEXT_KEYS, - type ChargebeeEntitlement, - type ChargebeeEntitlementsSnapshot, - type ChargebeeEvaluationMode, - type ChargebeeTarget, -} from "./shared"; + type EntitlementsRelayHandler, +} from "./relay"; diff --git a/packages/openfeature/src/server/provider.ts b/packages/openfeature/src/server/provider.ts index 936512e..be88399 100644 --- a/packages/openfeature/src/server/provider.ts +++ b/packages/openfeature/src/server/provider.ts @@ -6,51 +6,97 @@ import type { Provider, ResolutionDetails, } from "@openfeature/server-sdk"; -import { - createEntitlementsCacheKey, - TieredEntitlementsCache, - type TieredEntitlementsCacheOptions, -} from "../cache"; +import { createEntitlementsCacheKey, type EntitlementsStorage } from "../cache"; import { type ChargebeeEntitlementsSnapshot, type ChargebeeEvaluationMode, type ChargebeeTarget, createEntitlementsSnapshot, type EntitlementResolution, + errorResolution, getTargetFromContext, + isSnapshotExpired, resolveBooleanEntitlement, resolveNumberEntitlement, resolveObjectEntitlement, resolveStringEntitlement, + type SnapshotSource, + toResolutionDetails, } from "../shared"; import { type ChargebeeEntitlementsClient, ChargebeeEntitlementsLoader, } from "./loader"; +/** + * 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 the caller can fall back to its own defaults. + */ +export type RefreshOnMiss = "blocking" | "background"; + +export 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 ChargebeeEntitlementsProviderOptions { chargebeeClient: ChargebeeEntitlementsClient; defaultMode?: ChargebeeEvaluationMode; consolidateCustomerEntitlements?: boolean; resolveTarget?: (context: EvaluationContext) => ChargebeeTarget; - cache?: TieredEntitlementsCache | TieredEntitlementsCacheOptions; + /** Fast shared cache (Redis or similar) read before the store. */ + cache?: EntitlementsStorage; + /** Cache expiry. Defaults to whatever the cache implementation applies. */ + cacheTtlMs?: number; + /** Durable snapshot store treated as the source of truth. */ + store?: EntitlementsStorage; cacheNamespace?: string; + snapshotTtlMs?: number; + refreshOnMiss?: RefreshOnMiss; + /** Minimum gap between background refresh attempts after a failure. */ + refreshBackoffMs?: number; + onSnapshotRefreshed?: (event: SnapshotRefreshedEvent) => void; + onError?: (error: unknown, info: SnapshotErrorInfo) => void; pageSize?: number; maxPages?: number; } export interface EntitlementsSnapshotResult { snapshot: ChargebeeEntitlementsSnapshot; - source: "api" | "memory" | "redis"; -} - -interface InFlightSnapshotRequest { - promise: Promise; - state: { invalidated: boolean }; + source: Exclude; } class InvalidEntitlementContextError extends Error {} +/** + * Thrown when no snapshot is available locally and the provider 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"); + } +} + export class ChargebeeEntitlementsProvider implements Provider { readonly metadata = { name: "Chargebee Entitlements" } as const; readonly runsOn = "server" as const; @@ -60,10 +106,23 @@ export class ChargebeeEntitlementsProvider implements Provider { private readonly resolveTargetOption?: ( context: EvaluationContext, ) => ChargebeeTarget; - private readonly cache: TieredEntitlementsCache; + private readonly cache?: EntitlementsStorage; + private readonly cacheTtlMs?: number; + private readonly store?: EntitlementsStorage; private readonly cacheNamespace?: string; + private readonly snapshotTtlMs: number; + private readonly refreshOnMiss: RefreshOnMiss; + 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(); + private readonly inFlight = new Map< + string, + { promise: Promise; cancelled: boolean } + >(); + private readonly failedAt = new Map(); constructor(options: ChargebeeEntitlementsProviderOptions) { if (!options?.chargebeeClient) @@ -73,11 +132,23 @@ export class ChargebeeEntitlementsProvider implements Provider { this.consolidateCustomerEntitlements = options.consolidateCustomerEntitlements ?? true; this.resolveTargetOption = options.resolveTarget; - this.cache = - options.cache instanceof TieredEntitlementsCache - ? options.cache - : new TieredEntitlementsCache(options.cache); + this.cache = options.cache; + this.cacheTtlMs = options.cacheTtlMs; + this.store = options.store; this.cacheNamespace = options.cacheNamespace; + this.snapshotTtlMs = options.snapshotTtlMs ?? 300_000; + this.refreshOnMiss = options.refreshOnMiss ?? "blocking"; + this.refreshBackoffMs = options.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, @@ -87,11 +158,9 @@ export class ChargebeeEntitlementsProvider implements Provider { } async onClose(): Promise { - for (const request of this.inFlight.values()) { - request.state.invalidated = true; - } + for (const request of this.inFlight.values()) request.cancelled = true; this.inFlight.clear(); - await this.cache.clearMemory(); + this.failedAt.clear(); } resolveBooleanEvaluation( @@ -158,28 +227,104 @@ export class ChargebeeEntitlementsProvider implements Provider { ); } + /** + * 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( context: EvaluationContext, logger?: Logger, ): Promise { const target = this.resolveTarget(context); const key = this.cacheKey(target); - const cached = await this.cache.get(key); - if (cached) return cached; - const current = this.inFlight.get(key); - if (current) return current.promise; + const cached = await this.read(this.cache, key, target, "cache-read"); + if (cached) { + this.refreshIfExpired(cached, target, key, logger); + return { snapshot: cached, source: "cache" }; + } - const state = { invalidated: false }; - const promise = this.loadSnapshot(target, key, state, logger).finally( - () => { - if (this.inFlight.get(key)?.promise === promise) { - this.inFlight.delete(key); - } - }, - ); - this.inFlight.set(key, { promise, state }); - return promise; + const stored = await this.read(this.store, key, target, "store-read"); + if (stored) { + await this.write(this.cache, key, stored, target, "cache-write"); + this.refreshIfExpired(stored, target, key, logger); + return { snapshot: stored, source: "store" }; + } + + if (this.refreshOnMiss === "background") { + this.scheduleRefresh(target, key, logger); + throw new SnapshotPendingError(target); + } + + return { + snapshot: await this.fetchAndPersistSnapshot( + target, + key, + "request", + logger, + ), + 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, + logger?: Logger, + ): Promise { + const key = this.cacheKey(target); + // 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, target); + this.cancelInFlight(key); + return { + snapshot: await this.fetchAndPersistSnapshot( + target, + key, + "explicit", + logger, + ), + source: "api", + }; + } + + /** Writes a snapshot assembled elsewhere, e.g. from a webhook payload. */ + async writeSnapshot( + target: ChargebeeTarget, + snapshot: ChargebeeEntitlementsSnapshot, + ): Promise { + if (snapshot.targetMode !== target.mode) { + throw new Error( + `Snapshot target mode ${snapshot.targetMode} does not match ${target.mode}`, + ); + } + const key = this.cacheKey(target); + this.cancelInFlight(key); + await this.persist(key, snapshot, target); + } + + /** Removes the snapshot from both the cache and the store. */ + async deleteSnapshot(target: ChargebeeTarget): Promise { + const key = this.cacheKey(target); + this.cancelInFlight(key); + await this.evictCache(key, target); + await this.store?.delete(key); + } + + /** Drops the cached copy, leaving the store as the next read's source. */ + async evictCachedSnapshot(target: ChargebeeTarget): Promise { + await this.evictCache(this.cacheKey(target), target); } async getRelaySnapshot( @@ -196,18 +341,6 @@ export class ChargebeeEntitlementsProvider implements Provider { }; } - async invalidate(target: ChargebeeTarget): Promise { - const key = this.cacheKey(target); - const current = this.inFlight.get(key); - if (current) current.state.invalidated = true; - this.inFlight.delete(key); - await this.cache.delete(key); - } - - clearMemoryCache(): Promise { - return this.cache.clearMemory(); - } - private async evaluate( defaultValue: T, context: EvaluationContext, @@ -215,72 +348,177 @@ export class ChargebeeEntitlementsProvider implements Provider { resolve: (result: EntitlementsSnapshotResult) => EntitlementResolution, ): Promise> { try { - return this.toResolution( + return toResolutionDetails( resolve(await this.getSnapshot(context, logger)), ); } catch (error) { - return this.errorResolution(defaultValue, error); + if (error instanceof SnapshotPendingError) { + return { + value: defaultValue, + reason: "STALE", + flagMetadata: { snapshotPending: true }, + }; + } + return toResolutionDetails( + errorResolution( + defaultValue, + error instanceof InvalidEntitlementContextError + ? "INVALID_CONTEXT" + : "GENERAL", + error instanceof Error + ? error.message + : "Unable to evaluate Chargebee entitlement", + ), + ); } } - private resolveTarget(context: EvaluationContext): ChargebeeTarget { + private async read( + storage: EntitlementsStorage | undefined, + key: string, + target: ChargebeeTarget, + operation: Extract, + ): Promise { + if (!storage) return undefined; try { - return this.resolveTargetOption - ? this.resolveTargetOption(context) - : getTargetFromContext(context, this.defaultMode); + return await storage.get(key); } catch (error) { - throw new InvalidEntitlementContextError( - error instanceof Error ? error.message : "Invalid evaluation context", + // A storage outage degrades to the next link in the chain. + this.onError?.(error, { operation, target }); + return undefined; + } + } + + private async write( + storage: EntitlementsStorage | undefined, + key: string, + snapshot: ChargebeeEntitlementsSnapshot, + target: ChargebeeTarget, + operation: Extract, + ): Promise { + if (!storage) return; + try { + await storage.set( + key, + snapshot, + operation === "cache-write" ? this.cacheTtlMs : this.snapshotTtlMs, ); + } catch (error) { + this.onError?.(error, { operation, target }); } } - private cacheKey(target: ChargebeeTarget): string { - return createEntitlementsCacheKey(target, { - namespace: this.cacheNamespace, - consolidateCustomerEntitlements: this.consolidateCustomerEntitlements, - }); + private async evictCache( + key: string, + target: ChargebeeTarget, + ): Promise { + if (!this.cache) return; + try { + await this.cache.delete(key); + } catch (error) { + this.onError?.(error, { operation: "cache-delete", target }); + } } - private async loadSnapshot( + /** + * 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, - state: { invalidated: boolean }, logger?: Logger, - ): Promise { - const snapshot = createEntitlementsSnapshot( - target.mode, - await this.loader.load(target, logger), - this.cache.snapshotTtlMs, + ): void { + if (isSnapshotExpired(snapshot)) this.scheduleRefresh(target, key, logger); + } + + private scheduleRefresh( + target: ChargebeeTarget, + key: string, + logger?: Logger, + ): void { + const failedAt = this.failedAt.get(key); + if (failedAt !== undefined && Date.now() - failedAt < this.refreshBackoffMs) + return; + + void this.fetchAndPersistSnapshot(target, key, "request", logger).catch( + () => { + // Reported through onError inside fetchAndPersistSnapshot. + }, ); - if (!state.invalidated) await this.cache.set(key, snapshot); - return { snapshot, source: "api" }; } - private errorResolution( - defaultValue: T, - error: unknown, - ): ResolutionDetails { - return { - value: defaultValue, - reason: "ERROR", - errorCode: (error instanceof InvalidEntitlementContextError - ? "INVALID_CONTEXT" - : "GENERAL") as ErrorCode, - errorMessage: - error instanceof Error - ? error.message - : "Unable to evaluate Chargebee entitlement", + private fetchAndPersistSnapshot( + target: ChargebeeTarget, + key: string, + trigger: SnapshotRefreshedEvent["trigger"], + logger?: Logger, + ): 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( + target.mode, + await this.loader.load(target, logger), + 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; } - private toResolution( - resolution: EntitlementResolution, - ): ResolutionDetails { - return { - ...resolution, - errorCode: resolution.errorCode as ErrorCode | undefined, - }; + /** The store is the source of truth, so its write failures propagate. */ + private async persist( + key: string, + snapshot: ChargebeeEntitlementsSnapshot, + target: ChargebeeTarget, + ): Promise { + if (this.store) await this.store.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(context: EvaluationContext): ChargebeeTarget { + try { + return this.resolveTargetOption + ? this.resolveTargetOption(context) + : getTargetFromContext(context, this.defaultMode); + } catch (error) { + throw new InvalidEntitlementContextError( + error instanceof Error ? error.message : "Invalid evaluation context", + ); + } + } + + private cacheKey(target: ChargebeeTarget): string { + return createEntitlementsCacheKey(target, { + namespace: this.cacheNamespace, + consolidateCustomerEntitlements: this.consolidateCustomerEntitlements, + }); } } diff --git a/packages/openfeature/src/server/relay.ts b/packages/openfeature/src/server/relay.ts index 718bf57..8ea7666 100644 --- a/packages/openfeature/src/server/relay.ts +++ b/packages/openfeature/src/server/relay.ts @@ -2,13 +2,19 @@ import type { EvaluationContext } from "@openfeature/server-sdk"; import { CHARGEBEE_CONTEXT_KEYS } from "../shared"; import type { ChargebeeEntitlementsProvider } from "./provider"; -export interface CreateEntitlementsRelayHandlerOptions { +export type EntitlementsRelayHandler = ( + request: TRequest, +) => Promise; + +export interface CreateEntitlementsRelayHandlerOptions< + TRequest extends Request = Request, +> { provider: ChargebeeEntitlementsProvider; resolveContext: ( - request: Request, + request: TRequest, ) => EvaluationContext | null | Promise; snapshotTtlMs?: number; - onError?: (error: unknown, request: Request) => Response | Promise; + onError?: (error: unknown, request: TRequest) => Response | Promise; } const responseHeaders = { @@ -22,9 +28,11 @@ const json = ( headers: HeadersInit = responseHeaders, ) => Response.json(body, { status, headers }); -export function createEntitlementsRelayHandler( - options: CreateEntitlementsRelayHandlerOptions, -): (request: Request) => Promise { +export function createEntitlementsRelayHandler< + TRequest extends Request = Request, +>( + options: CreateEntitlementsRelayHandlerOptions, +): EntitlementsRelayHandler { return async (request) => { try { if (request.method !== "GET") { diff --git a/packages/openfeature/src/shared/evaluation.ts b/packages/openfeature/src/shared/evaluation.ts index f710d7d..ec3f35e 100644 --- a/packages/openfeature/src/shared/evaluation.ts +++ b/packages/openfeature/src/shared/evaluation.ts @@ -1,9 +1,9 @@ import type { ChargebeeEntitlement, ChargebeeEntitlementsSnapshot, - EntitlementCacheSource, EntitlementErrorCode, EntitlementResolution, + SnapshotSource, } from "./types"; type EnabledEntitlement = { @@ -13,7 +13,7 @@ type EnabledEntitlement = { function metadataFor( entitlement: ChargebeeEntitlement, - source: EntitlementCacheSource, + source: SnapshotSource, ): Record { return { chargebeeFeatureId: entitlement.featureId, @@ -37,7 +37,7 @@ function metadataFor( }; } -function error( +export function errorResolution( value: T, errorCode: EntitlementErrorCode, errorMessage: string, @@ -45,15 +45,31 @@ function error( return { value, reason: "ERROR", errorCode, errorMessage }; } +/** + * 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, + }; +} + function getEnabled( snapshot: ChargebeeEntitlementsSnapshot, flagKey: string, defaultValue: T, - source: EntitlementCacheSource, + source: SnapshotSource, ): EnabledEntitlement | EntitlementResolution { const entitlement = snapshot.entitlements[flagKey]; if (!entitlement) { - return error( + return errorResolution( defaultValue, "FLAG_NOT_FOUND", `Chargebee feature ${flagKey} was not found`, @@ -76,14 +92,14 @@ function getEnabled( return { entitlement, metadata }; } -const reasonFor = (source: EntitlementCacheSource) => +const reasonFor = (source: SnapshotSource) => source === "api" ? "TARGETING_MATCH" : "CACHED"; export function resolveBooleanEntitlement( snapshot: ChargebeeEntitlementsSnapshot, flagKey: string, defaultValue: boolean, - source: EntitlementCacheSource, + source: SnapshotSource, ): EntitlementResolution { const found = getEnabled(snapshot, flagKey, defaultValue, source); if (!("entitlement" in found)) return found; @@ -102,7 +118,7 @@ export function resolveBooleanEntitlement( }; } if (!["true", "false", "available"].includes(normalized ?? "")) { - return error( + return errorResolution( defaultValue, "TYPE_MISMATCH", `Chargebee feature ${flagKey} is not a boolean entitlement`, @@ -122,14 +138,14 @@ export function resolveStringEntitlement( snapshot: ChargebeeEntitlementsSnapshot, flagKey: string, defaultValue: string, - source: EntitlementCacheSource, + source: SnapshotSource, ): EntitlementResolution { const found = getEnabled(snapshot, flagKey, defaultValue, source); if (!("entitlement" in found)) return found; const value = found.entitlement.value; if (value === undefined) { - return error( + return errorResolution( defaultValue, "PARSE_ERROR", `Chargebee feature ${flagKey} has no value`, @@ -148,7 +164,7 @@ export function resolveNumberEntitlement( snapshot: ChargebeeEntitlementsSnapshot, flagKey: string, defaultValue: number, - source: EntitlementCacheSource, + source: SnapshotSource, ): EntitlementResolution { const found = getEnabled(snapshot, flagKey, defaultValue, source); if (!("entitlement" in found)) return found; @@ -165,7 +181,7 @@ export function resolveNumberEntitlement( const value = rawValue ? Number(rawValue) : Number.NaN; if (!Number.isFinite(value)) { - return error( + return errorResolution( defaultValue, "TYPE_MISMATCH", `Chargebee feature ${flagKey} is not a numeric entitlement`, @@ -184,7 +200,7 @@ export function resolveObjectEntitlement( snapshot: ChargebeeEntitlementsSnapshot, flagKey: string, defaultValue: T, - source: EntitlementCacheSource, + source: SnapshotSource, ): EntitlementResolution { const found = getEnabled(snapshot, flagKey, defaultValue, source); if (!("entitlement" in found)) return found; diff --git a/packages/openfeature/src/shared.ts b/packages/openfeature/src/shared/index.ts similarity index 74% rename from packages/openfeature/src/shared.ts rename to packages/openfeature/src/shared/index.ts index de38973..20404f4 100644 --- a/packages/openfeature/src/shared.ts +++ b/packages/openfeature/src/shared/index.ts @@ -1,25 +1,27 @@ -export { getTargetFromContext } from "./shared/context"; +export { getTargetFromContext } from "./context"; export { + errorResolution, resolveBooleanEntitlement, resolveNumberEntitlement, resolveObjectEntitlement, resolveStringEntitlement, -} from "./shared/evaluation"; + toResolutionDetails, +} from "./evaluation"; export { createEntitlementsSnapshot, isSnapshotExpired, parseEntitlementsSnapshot, parseSerializedEntitlementsSnapshot, serializeEntitlementsSnapshot, -} from "./shared/snapshot"; +} from "./snapshot"; export { CHARGEBEE_CONTEXT_KEYS, type ChargebeeEntitlement, type ChargebeeEntitlementsSnapshot, type ChargebeeEvaluationMode, type ChargebeeTarget, - type EntitlementCacheSource, type EntitlementErrorCode, type EntitlementResolution, type EvaluationContextLike, -} from "./shared/types"; + type SnapshotSource, +} from "./types"; diff --git a/packages/openfeature/src/shared/types.ts b/packages/openfeature/src/shared/types.ts index 9797866..66ba5a1 100644 --- a/packages/openfeature/src/shared/types.ts +++ b/packages/openfeature/src/shared/types.ts @@ -34,7 +34,11 @@ export interface ChargebeeEntitlementsSnapshot { entitlements: Record; } -export type EntitlementCacheSource = "api" | "memory" | "redis" | "relay"; +/** + * 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" diff --git a/packages/openfeature/src/web.ts b/packages/openfeature/src/web.ts deleted file mode 100644 index e8b6d53..0000000 --- a/packages/openfeature/src/web.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type { - ChargebeeEntitlement, - ChargebeeEntitlementsSnapshot, -} from "./shared"; -export { - ChargebeeEntitlementsWebProvider, - type ChargebeeEntitlementsWebProviderOptions, -} from "./web/provider"; 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 index 9b77f64..9da4717 100644 --- a/packages/openfeature/src/web/provider.ts +++ b/packages/openfeature/src/web/provider.ts @@ -11,12 +11,14 @@ import { import { type ChargebeeEntitlementsSnapshot, type EntitlementResolution, + errorResolution, isSnapshotExpired, parseEntitlementsSnapshot, resolveBooleanEntitlement, resolveNumberEntitlement, resolveObjectEntitlement, resolveStringEntitlement, + toResolutionDetails, } from "../shared"; export interface ChargebeeEntitlementsWebProviderOptions { @@ -145,7 +147,7 @@ export class ChargebeeEntitlementsWebProvider implements Provider { ): ResolutionDetails { const snapshot = this.getUsableSnapshot(defaultValue); return "entitlements" in snapshot - ? this.toResolution(resolve(snapshot)) + ? toResolutionDetails(resolve(snapshot)) : snapshot; } @@ -153,12 +155,13 @@ export class ChargebeeEntitlementsWebProvider implements Provider { defaultValue: T, ): ChargebeeEntitlementsSnapshot | ResolutionDetails { if (!this.snapshot) { - return { - value: defaultValue, - reason: "ERROR", - errorCode: "PROVIDER_NOT_READY" as ErrorCode, - errorMessage: "Chargebee entitlement snapshot is not loaded", - }; + return toResolutionDetails( + errorResolution( + defaultValue, + "PROVIDER_NOT_READY", + "Chargebee entitlement snapshot is not loaded", + ), + ); } if (!isSnapshotExpired(this.snapshot)) return this.snapshot; @@ -184,15 +187,6 @@ export class ChargebeeEntitlementsWebProvider implements Provider { return { value: defaultValue, reason: "STALE" }; } - private toResolution( - resolution: EntitlementResolution, - ): ResolutionDetails { - return { - ...resolution, - errorCode: resolution.errorCode as ErrorCode | undefined, - }; - } - private async loadSnapshot(emitChange: boolean): Promise { if (this.closed) throw new Error("Chargebee web provider is closed"); diff --git a/packages/openfeature/test/cache.test.ts b/packages/openfeature/test/cache.test.ts index cacbda5..482d6fe 100644 --- a/packages/openfeature/test/cache.test.ts +++ b/packages/openfeature/test/cache.test.ts @@ -1,10 +1,23 @@ import { + createMemoryEntitlementsCache, createRedisEntitlementsCache, - MemoryEntitlementsCache, - TieredEntitlementsCache, + 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( "customer", @@ -13,10 +26,10 @@ function makeSnapshot(ttlMs = 60_000) { ); } -describe("MemoryEntitlementsCache", () => { +describe("memory entitlement cache", () => { it("expires entries and evicts the least recently used entry", async () => { let now = 1_000; - const cache = new MemoryEntitlementsCache({ + const cache = createMemoryEntitlementsCache({ maxEntries: 2, now: () => now, }); @@ -32,21 +45,25 @@ describe("MemoryEntitlementsCache", () => { 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 commands = { - get: vi.fn(async (key: string) => values.get(key)), - set: vi.fn(async (key: string, value: string) => { - values.set(key, value); - }), - delete: vi.fn(async (key: string) => { - values.delete(key); - }), - }; - const cache = createRedisEntitlementsCache(commands); + const { client, mocks } = makeRedisClient(values); + const cache = createRedisEntitlementsCache(client); const snapshot = makeSnapshot(); await cache.set("valid", snapshot, 300_000); @@ -54,45 +71,40 @@ describe("Redis entitlement cache", () => { values.set("invalid", "{not-json"); expect(await cache.get("invalid")).toBeUndefined(); - expect(commands.delete).toHaveBeenCalledWith("invalid"); + expect(mocks.del).toHaveBeenCalledWith("invalid"); }); -}); -describe("TieredEntitlementsCache", () => { - it("hydrates memory from Redis and falls back when Redis fails", async () => { + 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(); - const memory = new MemoryEntitlementsCache(); - const redis = { - get: vi.fn(async () => snapshot), - set: vi.fn(async () => undefined), - delete: vi.fn(async () => undefined), - }; - const onError = vi.fn(); - const cache = new TieredEntitlementsCache({ memory, redis, onError }); - - expect(await cache.get("target")).toMatchObject({ source: "redis", snapshot }); - expect(await cache.get("target")).toMatchObject({ source: "memory", snapshot }); - expect(redis.get).toHaveBeenCalledTimes(1); - - redis.get.mockRejectedValueOnce(new Error("redis unavailable")); - expect(await cache.get("another-target")).toBeUndefined(); - expect(onError).toHaveBeenCalledWith(expect.any(Error), "redis"); + + 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("invalidates both layers", async () => { - const snapshot = makeSnapshot(); - const memory = new MemoryEntitlementsCache(); - const redis = { - get: vi.fn(async () => undefined), - set: vi.fn(async () => undefined), - delete: vi.fn(async () => undefined), - }; - const cache = new TieredEntitlementsCache({ memory, redis }); - - await cache.set("target", snapshot); - await cache.delete("target"); - - expect(await memory.get("target")).toBeUndefined(); - expect(redis.delete).toHaveBeenCalledWith("target"); + 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/openfeature/test/server.test.ts b/packages/openfeature/test/server.test.ts index 36780a5..24882b1 100644 --- a/packages/openfeature/test/server.test.ts +++ b/packages/openfeature/test/server.test.ts @@ -3,12 +3,16 @@ import { OpenFeature, } from "@openfeature/server-sdk"; import type { CustomerEntitlement } from "chargebee"; -import { MemoryEntitlementsCache } from "../src/cache"; +import { + createMemoryEntitlementsCache, + type EntitlementsStorage, +} from "../src/cache"; import { type ChargebeeEntitlementsClient, ChargebeeEntitlementsProvider, createEntitlementsRelayHandler, } from "../src/server"; +import type { ChargebeeEntitlementsSnapshot } from "../src/shared"; function makeClient( customerPages: Array<{ @@ -37,6 +41,33 @@ const context: EvaluationContext = { chargebeeCustomerId: "customer-1", }; +/** A durable store keeps snapshots past `expiresAt`; the provider 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, + }, + }, + ], +}; + afterEach(async () => { await OpenFeature.clearProviders(); }); @@ -72,7 +103,8 @@ describe("ChargebeeEntitlementsProvider", () => { ]); const provider = new ChargebeeEntitlementsProvider({ chargebeeClient: client, - cache: { memory: new MemoryEntitlementsCache(), memoryTtlMs: 60_000 }, + cache: createMemoryEntitlementsCache(), + cacheTtlMs: 60_000, }); const first = await provider.resolveBooleanEvaluation( @@ -163,7 +195,7 @@ describe("ChargebeeEntitlementsProvider", () => { const first = provider.getSnapshot(context); await started; - await provider.invalidate({ mode: "customer", customerId: "customer-1" }); + await provider.deleteSnapshot({ mode: "customer", customerId: "customer-1" }); release?.(); await first; await provider.getSnapshot(context); @@ -242,6 +274,236 @@ describe("ChargebeeEntitlementsProvider", () => { ); }); + 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 ChargebeeEntitlementsProvider({ + chargebeeClient: client, + store, + }).refreshSnapshot({ mode: "customer", customerId: "customer-1" }); + + const reader = new ChargebeeEntitlementsProvider({ + chargebeeClient: client, + cache, + store, + refreshOnMiss: "background", + }); + + await expect(reader.getSnapshot(context)).resolves.toMatchObject({ + source: "store", + }); + await expect(reader.getSnapshot(context)).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 provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: client, + cache: { + get: vi.fn(async () => { + throw new Error("redis unavailable"); + }), + set: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + }, + store, + onError, + }); + await provider.refreshSnapshot({ + mode: "customer", + customerId: "customer-1", + }); + + await expect( + provider.resolveBooleanEvaluation("sso", false, context, console), + ).resolves.toMatchObject({ + value: true, + reason: "CACHED", + flagMetadata: { cacheSource: "store" }, + }); + expect(onError).toHaveBeenCalledWith(expect.any(Error), { + operation: "cache-read", + target: { mode: "customer", 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 provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: client, + cache, + store, + }); + const target = { mode: "customer", customerId: "customer-1" } as const; + + await provider.refreshSnapshot(target); + await provider.getSnapshot(context); + await provider.refreshSnapshot(target); + + expect(evict).toHaveBeenCalledTimes(2); + expect(evict).toHaveBeenCalledWith( + "chargebee:openfeature: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 provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: { + customerEntitlement: { entitlementsForCustomer: customerRequest }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: vi.fn(), + }, + } as unknown as ChargebeeEntitlementsClient, + cache: createMemoryEntitlementsCache(), + store: makeDurableStore(), + refreshOnMiss: "background", + onSnapshotRefreshed, + }); + const target = { mode: "customer", customerId: "customer-1" } as const; + + await expect( + provider.resolveBooleanEvaluation("sso", false, context, console), + ).resolves.toMatchObject({ reason: "STALE" }); + await vi.waitFor(() => expect(customerRequest).toHaveBeenCalledTimes(1)); + + const explicitRefresh = provider.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(provider.getSnapshot(context)).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 provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: client, + store: createMemoryEntitlementsCache(), + refreshOnMiss: "background", + onSnapshotRefreshed, + }); + + await expect( + provider.resolveBooleanEvaluation("sso", false, context, console), + ).resolves.toEqual({ + value: false, + reason: "STALE", + flagMetadata: { snapshotPending: true }, + }); + + await vi.waitFor(() => + expect(onSnapshotRefreshed).toHaveBeenCalledWith( + expect.objectContaining({ trigger: "request" }), + ), + ); + await expect( + provider.resolveBooleanEvaluation("sso", false, context, console), + ).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 provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: client, + store: makeDurableStore(), + snapshotTtlMs: 1, + refreshOnMiss: "background", + }); + await provider.refreshSnapshot({ + mode: "customer", + customerId: "customer-1", + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + + await expect(provider.getSnapshot(context)).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 provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: { + customerEntitlement: { entitlementsForCustomer: customerRequest }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: vi.fn(), + }, + } as unknown as ChargebeeEntitlementsClient, + refreshOnMiss: "background", + refreshBackoffMs: 60_000, + onError, + }); + + await provider.resolveBooleanEvaluation("sso", false, context, console); + await vi.waitFor(() => + expect(onError).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ operation: "refresh" }), + ), + ); + await provider.resolveBooleanEvaluation("sso", false, context, console); + + expect(customerRequest).toHaveBeenCalledTimes(1); + }); + it("works through the OpenFeature server SDK", async () => { const { client } = makeClient([ { diff --git a/packages/openfeature/test/shared.test.ts b/packages/openfeature/test/shared.test.ts index ef549c7..5165b2d 100644 --- a/packages/openfeature/test/shared.test.ts +++ b/packages/openfeature/test/shared.test.ts @@ -40,7 +40,7 @@ describe("entitlement mapping", () => { reason: "TARGETING_MATCH", }); expect( - resolveBooleanEntitlement(snapshot, "switch-off", true, "memory"), + resolveBooleanEntitlement(snapshot, "switch-off", true, "cache"), ).toMatchObject({ value: false, reason: "CACHED", @@ -54,7 +54,7 @@ describe("entitlement mapping", () => { }); it("maps numeric and unlimited entitlements", () => { - expect(resolveNumberEntitlement(snapshot, "seats", 0, "redis")).toMatchObject( + expect(resolveNumberEntitlement(snapshot, "seats", 0, "store")).toMatchObject( { value: 25, reason: "CACHED", diff --git a/packages/openfeature/tsdown.config.ts b/packages/openfeature/tsdown.config.ts index e0d53b3..bde64f3 100644 --- a/packages/openfeature/tsdown.config.ts +++ b/packages/openfeature/tsdown.config.ts @@ -3,17 +3,18 @@ import { defineConfig } from "tsdown"; export default defineConfig({ dts: { build: true, incremental: true }, format: ["esm"], - entry: [ - "./src/index.ts", - "./src/cache.ts", - "./src/server.ts", - "./src/web.ts", - "./src/nextjs.ts", - ], + 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: [ "@openfeature/server-sdk", "@openfeature/web-sdk", "chargebee", + "ioredis", "next", "next/server", "server-only", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea605f1..c57440e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,7 +80,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 @@ -112,7 +112,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: @@ -154,6 +154,9 @@ importers: 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) @@ -286,24 +289,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==} @@ -809,6 +816,9 @@ packages: 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==} @@ -1639,6 +1649,10 @@ packages: 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'} @@ -1696,6 +1710,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'} @@ -2059,6 +2082,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'} @@ -2468,6 +2495,10 @@ 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==} @@ -2644,6 +2675,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'} @@ -3416,6 +3450,8 @@ snapshots: '@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 @@ -3430,9 +3466,9 @@ 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.1(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -3994,11 +4030,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.1(supports-color@7.2.0) http-errors: 2.0.0 iconv-lite: 0.6.3 on-finished: 2.4.1 @@ -4121,6 +4157,8 @@ snapshots: 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 @@ -4167,9 +4205,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: @@ -4182,8 +4228,7 @@ snapshots: defu@6.1.4: {} - denque@2.1.0: - optional: true + denque@2.1.0: {} depd@2.0.0: {} @@ -4297,19 +4342,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 @@ -4320,9 +4365,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 @@ -4345,9 +4390,9 @@ snapshots: 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.1(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -4473,6 +4518,17 @@ 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: @@ -4870,6 +4926,8 @@ snapshots: readdirp@4.1.2: optional: true + redis-errors@1.2.0: {} + regexp-to-ast@0.5.0: optional: true @@ -4952,9 +5010,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.1(supports-color@7.2.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -4976,9 +5034,9 @@ snapshots: 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.1(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -4995,12 +5053,12 @@ 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 @@ -5126,11 +5184,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 @@ -5155,6 +5213,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + statuses@2.0.1: {} std-env@3.10.0: {} From b4755960c26ddca87a4f431cbb3a5a3a40f45371 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Wed, 12 Aug 2026 20:48:56 +1000 Subject: [PATCH 10/22] split into `@chargebee/entitlements` and `@chargebee/openfeature` packages --- TODO.md | 35 ++ packages/entitlements/CHANGELOG.md | 15 + packages/entitlements/LICENSE.md | 20 + packages/entitlements/README.md | 293 +++++++++ packages/entitlements/package.json | 123 ++++ .../src/cache/index.ts | 0 .../src/cache/key.ts | 2 +- .../src/cache/memory.ts | 0 .../src/cache/redis.ts | 0 .../src/cache/types.ts | 0 packages/entitlements/src/index.ts | 21 + .../src/nextjs.ts | 6 +- .../entitlements/src/server/entitlements.ts | 532 ++++++++++++++++ packages/entitlements/src/server/index.ts | 17 + .../src/server/loader.ts | 3 +- .../src/server/relay.ts | 30 +- .../src/shared/context.ts | 0 .../src/shared/evaluation.ts | 0 .../src/shared/index.ts | 1 + .../src/shared/snapshot.ts | 0 .../src/shared/types.ts | 11 + packages/entitlements/src/web/client.ts | 213 +++++++ packages/entitlements/src/web/index.ts | 4 + .../test/cache.test.ts | 0 packages/entitlements/test/server.test.ts | 556 +++++++++++++++++ .../test/shared.test.ts | 0 packages/entitlements/test/web.test.ts | 109 ++++ packages/entitlements/tsconfig.json | 24 + packages/entitlements/tsdown.config.ts | 15 + packages/entitlements/vitest.config.ts | 20 + packages/openfeature/CHANGELOG.md | 22 + packages/openfeature/README.md | 246 ++------ packages/openfeature/package.json | 40 +- packages/openfeature/src/index.ts | 5 +- packages/openfeature/src/server/index.ts | 12 - packages/openfeature/src/server/provider.ts | 502 ++------------- packages/openfeature/src/web/provider.ts | 211 ++----- packages/openfeature/test/server.test.ts | 589 ++---------------- packages/openfeature/test/web.test.ts | 39 +- packages/openfeature/tsconfig.json | 8 +- packages/openfeature/tsdown.config.ts | 10 +- packages/openfeature/vitest.config.ts | 7 + pnpm-lock.yaml | 66 +- pnpm-workspace.yaml | 1 + 44 files changed, 2370 insertions(+), 1438 deletions(-) create mode 100644 TODO.md create mode 100644 packages/entitlements/CHANGELOG.md create mode 100644 packages/entitlements/LICENSE.md create mode 100644 packages/entitlements/README.md create mode 100644 packages/entitlements/package.json rename packages/{openfeature => entitlements}/src/cache/index.ts (100%) rename packages/{openfeature => entitlements}/src/cache/key.ts (88%) rename packages/{openfeature => entitlements}/src/cache/memory.ts (100%) rename packages/{openfeature => entitlements}/src/cache/redis.ts (100%) rename packages/{openfeature => entitlements}/src/cache/types.ts (100%) create mode 100644 packages/entitlements/src/index.ts rename packages/{openfeature => entitlements}/src/nextjs.ts (74%) create mode 100644 packages/entitlements/src/server/entitlements.ts create mode 100644 packages/entitlements/src/server/index.ts rename packages/{openfeature => entitlements}/src/server/loader.ts (96%) rename packages/{openfeature => entitlements}/src/server/relay.ts (70%) rename packages/{openfeature => entitlements}/src/shared/context.ts (100%) rename packages/{openfeature => entitlements}/src/shared/evaluation.ts (100%) rename packages/{openfeature => entitlements}/src/shared/index.ts (98%) rename packages/{openfeature => entitlements}/src/shared/snapshot.ts (100%) rename packages/{openfeature => entitlements}/src/shared/types.ts (82%) create mode 100644 packages/entitlements/src/web/client.ts create mode 100644 packages/entitlements/src/web/index.ts rename packages/{openfeature => entitlements}/test/cache.test.ts (100%) create mode 100644 packages/entitlements/test/server.test.ts rename packages/{openfeature => entitlements}/test/shared.test.ts (100%) create mode 100644 packages/entitlements/test/web.test.ts create mode 100644 packages/entitlements/tsconfig.json create mode 100644 packages/entitlements/tsdown.config.ts create mode 100644 packages/entitlements/vitest.config.ts diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..8bcea1a --- /dev/null +++ b/TODO.md @@ -0,0 +1,35 @@ +# Beta + +- [x] Pull in live data in the `apiPayload` method + +- [x] Optionally get the prefix (/chargebee) from the user/let them replace + +- [x] Set header for all API calls indicating the source tool (chargebee-init) and the framework (nextjs/express) + +- [x] Update cb-client-libs to accept a list of default headers for the client + +- [x] Cleanup creation of multiple clients for each middleware, make it a singleton + +- [x] Move existing readme to developer.md and add documentation for usage + +- [x] CI pipeline to at least build all the packages to maintain basic sanity + +# Nice to have + +- [ ] Write/append the required env variables to `.env` or `.env.example` + +- [ ] Automatically run the appropriate package manager by detecting which lockfile is present + +- [ ] Consolidate the initial checks (git/supported framework) into a single step and display the results to the user with an option of continuing if it's just a warning. This makes it very evident what is expected to run the tool + +- [ ] changesets/conventional commits for release + +- [ ] Read express/nextjs adapter version from package.json and pass to userAgentSuffix + +# Other topics discussed with KPS + +- [ ] Entitlements + +- [ ] Entitlements service sidecar?? + +- [ ] Better auth integration: what are the major features and how does the API interact? diff --git a/packages/entitlements/CHANGELOG.md b/packages/entitlements/CHANGELOG.md new file mode 100644 index 0000000..77f1228 --- /dev/null +++ b/packages/entitlements/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## Unreleased + +- 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..63b4954 --- /dev/null +++ b/packages/entitlements/README.md @@ -0,0 +1,293 @@ +# Chargebee Entitlements + +`@chargebee/entitlements` resolves Chargebee entitlements — customer-level +consolidated entitlements or subscription-level entitlements — into typed +boolean/string/number/object values, with a shared cache in front of a +durable snapshot store, background refresh, and an authenticated browser +relay for Next.js 16. It has no dependency on any feature-flag SDK; use it +directly, or wrap it with an adapter such as +[`@chargebee/openfeature`](https://github.com/chargebee/js-framework-adapters/blob/main/packages/openfeature/README.md). + +## Install + +```sh +pnpm add @chargebee/entitlements chargebee +``` + +## Server client + +Pass an initialized Chargebee client to the 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, +}); +``` + +Evaluate Chargebee feature IDs directly against a `ChargebeeTarget`: + +```ts +const enabled = await entitlements.getBooleanValue("advanced-reports", false, { + mode: "customer", + customerId: session.user.chargebeeCustomerId, +}); +``` + +For subscription-scoped evaluation: + +```ts +const seats = await entitlements.getNumberValue("licensed-seats", 0, { + mode: "subscription", + subscriptionId: subscription.id, +}); +``` + +You can also pass a context-shaped bag instead of an explicit target — +useful when the same object already carries an application's subject ID and +Chargebee identity (e.g. an OpenFeature evaluation context, or your own +request context): + +```ts +const enabled = await entitlements.getBooleanValue("advanced-reports", false, { + targetingKey: session.user.id, + chargebeeCustomerId: session.user.chargebeeCustomerId, +}); +``` + +`targetingKey` is never assumed to be a Chargebee ID. Replace the built-in +context keys with `resolveTarget(context)` when your application has a +different context model. + +## Entitlement mapping + +| Chargebee value | Resolved as | +| --- | --- | +| Switch (`true`, `false`, or `available`) | Boolean | +| Custom level | String | +| Quantity or range | Number | +| `unlimited` | `Number.POSITIVE_INFINITY` with `unlimited` metadata | +| Full sanitized entitlement | Object | + +Disabled or expired entitlements return the caller's default with the +`DISABLED` reason. Missing features return `FLAG_NOT_FOUND`; invalid value +types return `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. It absorbs the many + entitlement checks a single authenticated request makes. +2. `store` — 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, + store: postgresSnapshotStore, + snapshotTtlMs: 24 * 60 * 60_000, + 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"; + +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 +`refreshBackoffMs`. + +Refresh the snapshot after processing relevant Chargebee webhooks, and remove +it when the target no longer exists: + +```ts +await entitlements.refreshSnapshot({ mode: "subscription", subscriptionId }); +await entitlements.deleteSnapshot({ mode: "customer", 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 +`evictCachedSnapshot` on its own when you want that fall-through without +fetching, and `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. + +### 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, + store: postgresSnapshotStore, + refreshOnMiss: "background", + 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 { + targetingKey: session.user.id, + chargebeeCustomerId: session.user.chargebeeCustomerId, + }; + }, +}); +``` + +`resolveContext` must derive billing identity from the authenticated server +session. The relay rejects Chargebee identity 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`. + +Use the browser client directly: + +```ts +import { ChargebeeEntitlementsWebClient } from "@chargebee/entitlements/web"; + +const entitlements = new ChargebeeEntitlementsWebClient({ + relayUrl: "/api/entitlements", +}); + +await entitlements.initialize(); +const enabled = entitlements.getBooleanValue("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 `store`, 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..e8fc3fe --- /dev/null +++ b/packages/entitlements/package.json @@ -0,0 +1,123 @@ +{ + "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", + "zod": "^4.4.3" + }, + "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": true + }, + "ioredis": { + "optional": true + }, + "next": { + "optional": true + } + } +} diff --git a/packages/openfeature/src/cache/index.ts b/packages/entitlements/src/cache/index.ts similarity index 100% rename from packages/openfeature/src/cache/index.ts rename to packages/entitlements/src/cache/index.ts diff --git a/packages/openfeature/src/cache/key.ts b/packages/entitlements/src/cache/key.ts similarity index 88% rename from packages/openfeature/src/cache/key.ts rename to packages/entitlements/src/cache/key.ts index f367998..cd8e962 100644 --- a/packages/openfeature/src/cache/key.ts +++ b/packages/entitlements/src/cache/key.ts @@ -7,7 +7,7 @@ export function createEntitlementsCacheKey( consolidateCustomerEntitlements?: boolean; } = {}, ): string { - const namespace = options.namespace ?? "chargebee:openfeature:v1"; + const namespace = options.namespace ?? "chargebee:entitlements:v1"; if (target.mode === "subscription") { return `${namespace}:subscription:${encodeURIComponent(target.subscriptionId)}`; } diff --git a/packages/openfeature/src/cache/memory.ts b/packages/entitlements/src/cache/memory.ts similarity index 100% rename from packages/openfeature/src/cache/memory.ts rename to packages/entitlements/src/cache/memory.ts diff --git a/packages/openfeature/src/cache/redis.ts b/packages/entitlements/src/cache/redis.ts similarity index 100% rename from packages/openfeature/src/cache/redis.ts rename to packages/entitlements/src/cache/redis.ts diff --git a/packages/openfeature/src/cache/types.ts b/packages/entitlements/src/cache/types.ts similarity index 100% rename from packages/openfeature/src/cache/types.ts rename to packages/entitlements/src/cache/types.ts diff --git a/packages/entitlements/src/index.ts b/packages/entitlements/src/index.ts new file mode 100644 index 0000000..2e109e0 --- /dev/null +++ b/packages/entitlements/src/index.ts @@ -0,0 +1,21 @@ +export type { + ChargebeeEntitlement, + ChargebeeEntitlementsSnapshot, + ChargebeeEvaluationMode, + ChargebeeTarget, + EntitlementErrorCode, + EntitlementResolution, + EvaluationContextLike, + Logger, + SnapshotSource, +} from "./shared"; +export { + CHARGEBEE_CONTEXT_KEYS, + createEntitlementsSnapshot, + getTargetFromContext, + isSnapshotExpired, + parseEntitlementsSnapshot, + parseSerializedEntitlementsSnapshot, + serializeEntitlementsSnapshot, + toResolutionDetails, +} from "./shared"; diff --git a/packages/openfeature/src/nextjs.ts b/packages/entitlements/src/nextjs.ts similarity index 74% rename from packages/openfeature/src/nextjs.ts rename to packages/entitlements/src/nextjs.ts index 912b885..f332477 100644 --- a/packages/openfeature/src/nextjs.ts +++ b/packages/entitlements/src/nextjs.ts @@ -9,9 +9,9 @@ import { /** * Builds an App Router `GET` handler that resolves billing identity on the - * server and returns a sanitized entitlement snapshot for the browser - * provider. This is the Next.js `NextRequest` instantiation of - * `createEntitlementsRelayHandler` from `@chargebee/openfeature/server`. + * 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, diff --git a/packages/entitlements/src/server/entitlements.ts b/packages/entitlements/src/server/entitlements.ts new file mode 100644 index 0000000..9fb2e27 --- /dev/null +++ b/packages/entitlements/src/server/entitlements.ts @@ -0,0 +1,532 @@ +import { createEntitlementsCacheKey, type EntitlementsStorage } from "../cache"; +import { + type ChargebeeEntitlementsSnapshot, + type ChargebeeEvaluationMode, + type ChargebeeTarget, + createEntitlementsSnapshot, + type EntitlementResolution, + type EvaluationContextLike, + errorResolution, + getTargetFromContext, + isSnapshotExpired, + type Logger, + resolveBooleanEntitlement, + resolveNumberEntitlement, + resolveObjectEntitlement, + resolveStringEntitlement, + type SnapshotSource, +} from "../shared"; +import { + type ChargebeeEntitlementsClient, + ChargebeeEntitlementsLoader, +} from "./loader"; + +/** + * 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 the caller can fall back to its own defaults. + */ +export type RefreshOnMiss = "blocking" | "background"; + +export 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; + defaultMode?: ChargebeeEvaluationMode; + consolidateCustomerEntitlements?: boolean; + resolveTarget?: (context: EvaluationContextLike) => ChargebeeTarget; + /** Fast shared cache (Redis or similar) read before the store. */ + cache?: EntitlementsStorage; + /** Cache expiry. Defaults to whatever the cache implementation applies. */ + cacheTtlMs?: number; + /** Durable snapshot store treated as the source of truth. */ + store?: EntitlementsStorage; + cacheNamespace?: string; + snapshotTtlMs?: number; + refreshOnMiss?: RefreshOnMiss; + /** Minimum gap between background refresh attempts after a failure. */ + refreshBackoffMs?: number; + onSnapshotRefreshed?: (event: SnapshotRefreshedEvent) => void; + onError?: (error: unknown, info: SnapshotErrorInfo) => void; + 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"); + } +} + +function isChargebeeTarget( + target: ChargebeeTarget | EvaluationContextLike, +): target is ChargebeeTarget { + const candidate = target as Partial; + return candidate.mode === "customer" + ? typeof (candidate as { customerId?: unknown }).customerId === "string" + : candidate.mode === "subscription" + ? typeof (candidate as { subscriptionId?: unknown }).subscriptionId === + "string" + : false; +} + +/** + * 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 defaultMode: ChargebeeEvaluationMode; + private readonly consolidateCustomerEntitlements: boolean; + private readonly resolveTargetOption?: ( + context: EvaluationContextLike, + ) => ChargebeeTarget; + private readonly cache?: EntitlementsStorage; + private readonly cacheTtlMs?: number; + private readonly store?: EntitlementsStorage; + private readonly cacheNamespace?: string; + private readonly snapshotTtlMs: number; + private readonly refreshOnMiss: RefreshOnMiss; + 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.defaultMode = options.defaultMode ?? "customer"; + this.consolidateCustomerEntitlements = + options.consolidateCustomerEntitlements ?? true; + this.resolveTargetOption = options.resolveTarget; + this.cache = options.cache; + this.cacheTtlMs = options.cacheTtlMs; + this.store = options.store; + this.cacheNamespace = options.cacheNamespace; + this.snapshotTtlMs = options.snapshotTtlMs ?? 300_000; + this.refreshOnMiss = options.refreshOnMiss ?? "blocking"; + this.refreshBackoffMs = options.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.pageSize ?? 100, + maxPages: options.maxPages ?? 50, + }); + } + + async close(): Promise { + for (const request of this.inFlight.values()) request.cancelled = true; + this.inFlight.clear(); + this.failedAt.clear(); + } + + getBooleanValue( + flagKey: string, + defaultValue: boolean, + target: ChargebeeTarget | EvaluationContextLike, + logger?: Logger, + ): Promise> { + return this.evaluate(defaultValue, target, logger, (result) => + resolveBooleanEntitlement( + result.snapshot, + flagKey, + defaultValue, + result.source, + ), + ); + } + + getStringValue( + flagKey: string, + defaultValue: string, + target: ChargebeeTarget | EvaluationContextLike, + logger?: Logger, + ): Promise> { + return this.evaluate(defaultValue, target, logger, (result) => + resolveStringEntitlement( + result.snapshot, + flagKey, + defaultValue, + result.source, + ), + ); + } + + getNumberValue( + flagKey: string, + defaultValue: number, + target: ChargebeeTarget | EvaluationContextLike, + logger?: Logger, + ): Promise> { + return this.evaluate(defaultValue, target, logger, (result) => + resolveNumberEntitlement( + result.snapshot, + flagKey, + defaultValue, + result.source, + ), + ); + } + + getObjectValue( + flagKey: string, + defaultValue: T, + target: ChargebeeTarget | EvaluationContextLike, + logger?: Logger, + ): Promise> { + return this.evaluate(defaultValue, target, logger, (result) => + resolveObjectEntitlement( + result.snapshot, + flagKey, + defaultValue, + result.source, + ), + ); + } + + /** + * 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 | EvaluationContextLike, + logger?: Logger, + ): 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, logger); + return { snapshot: cached, source: "cache" }; + } + + const stored = await this.read(this.store, key, resolved, "store-read"); + if (stored) { + await this.write(this.cache, key, stored, resolved, "cache-write"); + this.refreshIfExpired(stored, resolved, key, logger); + return { snapshot: stored, source: "store" }; + } + + if (this.refreshOnMiss === "background") { + this.scheduleRefresh(resolved, key, logger); + throw new SnapshotPendingError(resolved); + } + + return { + snapshot: await this.fetchAndPersistSnapshot( + resolved, + key, + "request", + logger, + ), + 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, + logger?: Logger, + ): Promise { + const key = this.cacheKey(target); + // 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, target); + this.cancelInFlight(key); + return { + snapshot: await this.fetchAndPersistSnapshot( + target, + key, + "explicit", + logger, + ), + source: "api", + }; + } + + /** Writes a snapshot assembled elsewhere, e.g. from a webhook payload. */ + async writeSnapshot( + target: ChargebeeTarget, + snapshot: ChargebeeEntitlementsSnapshot, + ): Promise { + if (snapshot.targetMode !== target.mode) { + throw new Error( + `Snapshot target mode ${snapshot.targetMode} does not match ${target.mode}`, + ); + } + const key = this.cacheKey(target); + this.cancelInFlight(key); + await this.persist(key, snapshot, target); + } + + /** Removes the snapshot from both the cache and the store. */ + async deleteSnapshot(target: ChargebeeTarget): Promise { + const key = this.cacheKey(target); + this.cancelInFlight(key); + await this.evictCache(key, target); + await this.store?.delete(key); + } + + /** Drops the cached copy, leaving the store as the next read's source. */ + async evictCachedSnapshot(target: ChargebeeTarget): Promise { + await this.evictCache(this.cacheKey(target), target); + } + + async getRelaySnapshot( + target: ChargebeeTarget | EvaluationContextLike, + ttlMs = 60_000, + logger?: Logger, + ): Promise { + const { snapshot } = await this.getSnapshot(target, logger); + return { + ...snapshot, + expiresAt: new Date( + Math.min(Date.parse(snapshot.expiresAt), Date.now() + ttlMs), + ).toISOString(), + }; + } + + private async evaluate( + defaultValue: T, + target: ChargebeeTarget | EvaluationContextLike, + logger: Logger | undefined, + resolve: (result: EntitlementsSnapshotResult) => EntitlementResolution, + ): Promise> { + try { + return resolve(await this.getSnapshot(target, logger)); + } 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 async read( + storage: EntitlementsStorage | undefined, + key: string, + target: ChargebeeTarget, + operation: Extract, + ): Promise { + if (!storage) return undefined; + try { + return await storage.get(key); + } catch (error) { + // A storage outage degrades to the next link in the chain. + this.onError?.(error, { operation, target }); + return undefined; + } + } + + private async write( + storage: EntitlementsStorage | undefined, + key: string, + snapshot: ChargebeeEntitlementsSnapshot, + target: ChargebeeTarget, + operation: Extract, + ): Promise { + if (!storage) return; + try { + await storage.set( + key, + snapshot, + operation === "cache-write" ? this.cacheTtlMs : this.snapshotTtlMs, + ); + } catch (error) { + this.onError?.(error, { operation, target }); + } + } + + private async evictCache( + key: string, + target: ChargebeeTarget, + ): Promise { + if (!this.cache) return; + try { + await this.cache.delete(key); + } catch (error) { + this.onError?.(error, { operation: "cache-delete", target }); + } + } + + /** + * 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, + logger?: Logger, + ): void { + if (isSnapshotExpired(snapshot)) this.scheduleRefresh(target, key, logger); + } + + private scheduleRefresh( + target: ChargebeeTarget, + key: string, + logger?: Logger, + ): void { + const failedAt = this.failedAt.get(key); + if (failedAt !== undefined && Date.now() - failedAt < this.refreshBackoffMs) + return; + + void this.fetchAndPersistSnapshot(target, key, "request", logger).catch( + () => { + // Reported through onError inside fetchAndPersistSnapshot. + }, + ); + } + + private fetchAndPersistSnapshot( + target: ChargebeeTarget, + key: string, + trigger: SnapshotRefreshedEvent["trigger"], + logger?: Logger, + ): 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( + target.mode, + await this.loader.load(target, logger), + 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 store is the source of truth, so its write failures propagate. */ + private async persist( + key: string, + snapshot: ChargebeeEntitlementsSnapshot, + target: ChargebeeTarget, + ): Promise { + if (this.store) await this.store.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 | EvaluationContextLike, + ): ChargebeeTarget { + if (isChargebeeTarget(target)) return target; + try { + return this.resolveTargetOption + ? this.resolveTargetOption(target) + : getTargetFromContext(target, this.defaultMode); + } catch (error) { + throw new InvalidEntitlementContextError( + error instanceof Error ? error.message : "Invalid evaluation context", + ); + } + } + + 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..57c886e --- /dev/null +++ b/packages/entitlements/src/server/index.ts @@ -0,0 +1,17 @@ +export { + ChargebeeEntitlements, + type ChargebeeEntitlementsClient, + type ChargebeeEntitlementsOptions, + type EntitlementsSnapshotResult, + type RefreshOnMiss, + type SnapshotErrorInfo, + type SnapshotOperation, + SnapshotPendingError, + type SnapshotRefreshedEvent, +} from "./entitlements"; +export { + type CreateEntitlementsRelayHandlerOptions, + createEntitlementsRelayHandler, + type EntitlementsRelayHandler, + type EntitlementsRelaySource, +} from "./relay"; diff --git a/packages/openfeature/src/server/loader.ts b/packages/entitlements/src/server/loader.ts similarity index 96% rename from packages/openfeature/src/server/loader.ts rename to packages/entitlements/src/server/loader.ts index 7be11e3..b118984 100644 --- a/packages/openfeature/src/server/loader.ts +++ b/packages/entitlements/src/server/loader.ts @@ -1,7 +1,6 @@ -import type { Logger } from "@openfeature/server-sdk"; import type Chargebee from "chargebee"; import type { CustomerEntitlement, SubscriptionEntitlement } from "chargebee"; -import type { ChargebeeEntitlement, ChargebeeTarget } from "../shared"; +import type { ChargebeeEntitlement, ChargebeeTarget, Logger } from "../shared"; export type ChargebeeEntitlementsClient = Pick< Chargebee, diff --git a/packages/openfeature/src/server/relay.ts b/packages/entitlements/src/server/relay.ts similarity index 70% rename from packages/openfeature/src/server/relay.ts rename to packages/entitlements/src/server/relay.ts index 8ea7666..f21f906 100644 --- a/packages/openfeature/src/server/relay.ts +++ b/packages/entitlements/src/server/relay.ts @@ -1,18 +1,33 @@ -import type { EvaluationContext } from "@openfeature/server-sdk"; -import { CHARGEBEE_CONTEXT_KEYS } from "../shared"; -import type { ChargebeeEntitlementsProvider } from "./provider"; +import { + CHARGEBEE_CONTEXT_KEYS, + type ChargebeeEntitlementsSnapshot, + type ChargebeeTarget, + type EvaluationContextLike, +} 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 | EvaluationContextLike, + ttlMs?: number, + ): Promise; +} + export interface CreateEntitlementsRelayHandlerOptions< TRequest extends Request = Request, > { - provider: ChargebeeEntitlementsProvider; + entitlements: EntitlementsRelaySource; resolveContext: ( request: TRequest, - ) => EvaluationContext | null | Promise; + ) => + | ChargebeeTarget + | EvaluationContextLike + | null + | Promise; snapshotTtlMs?: number; onError?: (error: unknown, request: TRequest) => Response | Promise; } @@ -56,7 +71,10 @@ export function createEntitlementsRelayHandler< if (!context) return json({ error: "Unauthorized" }, 401); return json( - await options.provider.getRelaySnapshot(context, options.snapshotTtlMs), + await options.entitlements.getRelaySnapshot( + context, + options.snapshotTtlMs, + ), 200, ); } catch (error) { diff --git a/packages/openfeature/src/shared/context.ts b/packages/entitlements/src/shared/context.ts similarity index 100% rename from packages/openfeature/src/shared/context.ts rename to packages/entitlements/src/shared/context.ts diff --git a/packages/openfeature/src/shared/evaluation.ts b/packages/entitlements/src/shared/evaluation.ts similarity index 100% rename from packages/openfeature/src/shared/evaluation.ts rename to packages/entitlements/src/shared/evaluation.ts diff --git a/packages/openfeature/src/shared/index.ts b/packages/entitlements/src/shared/index.ts similarity index 98% rename from packages/openfeature/src/shared/index.ts rename to packages/entitlements/src/shared/index.ts index 20404f4..8fa0568 100644 --- a/packages/openfeature/src/shared/index.ts +++ b/packages/entitlements/src/shared/index.ts @@ -23,5 +23,6 @@ export { type EntitlementErrorCode, type EntitlementResolution, type EvaluationContextLike, + type Logger, type SnapshotSource, } from "./types"; diff --git a/packages/openfeature/src/shared/snapshot.ts b/packages/entitlements/src/shared/snapshot.ts similarity index 100% rename from packages/openfeature/src/shared/snapshot.ts rename to packages/entitlements/src/shared/snapshot.ts diff --git a/packages/openfeature/src/shared/types.ts b/packages/entitlements/src/shared/types.ts similarity index 82% rename from packages/openfeature/src/shared/types.ts rename to packages/entitlements/src/shared/types.ts index 66ba5a1..7de9ca6 100644 --- a/packages/openfeature/src/shared/types.ts +++ b/packages/entitlements/src/shared/types.ts @@ -14,6 +14,17 @@ export type EvaluationContextLike = Record & { targetingKey?: string; }; +/** + * 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; diff --git a/packages/entitlements/src/web/client.ts b/packages/entitlements/src/web/client.ts new file mode 100644 index 0000000..b9c41eb --- /dev/null +++ b/packages/entitlements/src/web/client.ts @@ -0,0 +1,213 @@ +import { + type ChargebeeEntitlementsSnapshot, + type EntitlementResolution, + errorResolution, + isSnapshotExpired, + parseEntitlementsSnapshot, + resolveBooleanEntitlement, + resolveNumberEntitlement, + resolveObjectEntitlement, + resolveStringEntitlement, +} 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); + } + + getBooleanValue( + flagKey: string, + defaultValue: boolean, + ): EntitlementResolution { + return this.evaluate(defaultValue, (snapshot) => + resolveBooleanEntitlement(snapshot, flagKey, defaultValue, "relay"), + ); + } + + getStringValue( + flagKey: string, + defaultValue: string, + ): EntitlementResolution { + return this.evaluate(defaultValue, (snapshot) => + resolveStringEntitlement(snapshot, flagKey, defaultValue, "relay"), + ); + } + + getNumberValue( + flagKey: string, + defaultValue: number, + ): EntitlementResolution { + return this.evaluate(defaultValue, (snapshot) => + resolveNumberEntitlement(snapshot, flagKey, defaultValue, "relay"), + ); + } + + getObjectValue( + flagKey: string, + defaultValue: T, + ): EntitlementResolution { + return this.evaluate(defaultValue, (snapshot) => + resolveObjectEntitlement(snapshot, flagKey, defaultValue, "relay"), + ); + } + + private evaluate( + defaultValue: T, + resolve: ( + snapshot: ChargebeeEntitlementsSnapshot, + ) => EntitlementResolution, + ): EntitlementResolution { + const snapshot = this.getUsableSnapshot(defaultValue); + return "entitlements" in snapshot ? resolve(snapshot) : snapshot; + } + + 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/openfeature/test/cache.test.ts b/packages/entitlements/test/cache.test.ts similarity index 100% rename from packages/openfeature/test/cache.test.ts rename to packages/entitlements/test/cache.test.ts diff --git a/packages/entitlements/test/server.test.ts b/packages/entitlements/test/server.test.ts new file mode 100644 index 0000000..7101137 --- /dev/null +++ b/packages/entitlements/test/server.test.ts @@ -0,0 +1,556 @@ +import type { CustomerEntitlement } from "chargebee"; +import { + createMemoryEntitlementsCache, + type EntitlementsStorage, +} from "../src/cache"; +import { + ChargebeeEntitlements, + type ChargebeeEntitlementsClient, + createEntitlementsRelayHandler, +} from "../src/server"; +import type { ChargebeeEntitlementsSnapshot, EvaluationContextLike } 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 context: EvaluationContextLike = { + targetingKey: "app-user-1", + chargebeeCustomerId: "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.getBooleanValue("sso", false, context); + const second = await entitlements.getNumberValue("seats", 0, context); + + 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(context); + const second = entitlements.getSnapshot(context); + 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(context); + await started; + await entitlements.deleteSnapshot({ mode: "customer", customerId: "customer-1" }); + release?.(); + await first; + await entitlements.getSnapshot(context); + + 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.getBooleanValue("sso", false, { targetingKey: "app-user-1" }), + ).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.getNumberValue("seats", 0, { + chargebeeEvaluationMode: "subscription", + chargebeeSubscriptionId: "subscription-1", + }), + ).resolves.toMatchObject({ + value: 50, + flagMetadata: { + chargebeeFeatureType: "quantity", + chargebeeOverridden: true, + }, + }); + expect(subscriptionRequest).toHaveBeenCalledWith( + "subscription-1", + expect.objectContaining({ limit: 100 }), + ); + }); + + it("evaluates directly against an explicit ChargebeeTarget", async () => { + const { client } = makeClient([ssoPage]); + const entitlements = new ChargebeeEntitlements({ chargebeeClient: client }); + + await expect( + entitlements.getBooleanValue("sso", false, { + mode: "customer", + customerId: "customer-1", + }), + ).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, + store, + }).refreshSnapshot({ mode: "customer", customerId: "customer-1" }); + + const reader = new ChargebeeEntitlements({ + chargebeeClient: client, + cache, + store, + refreshOnMiss: "background", + }); + + await expect(reader.getSnapshot(context)).resolves.toMatchObject({ + source: "store", + }); + await expect(reader.getSnapshot(context)).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), + }, + store, + onError, + }); + await entitlements.refreshSnapshot({ + mode: "customer", + customerId: "customer-1", + }); + + await expect( + entitlements.getBooleanValue("sso", false, context), + ).resolves.toMatchObject({ + value: true, + reason: "CACHED", + flagMetadata: { cacheSource: "store" }, + }); + expect(onError).toHaveBeenCalledWith(expect.any(Error), { + operation: "cache-read", + target: { mode: "customer", 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, + store, + }); + const target = { mode: "customer", customerId: "customer-1" } as const; + + await entitlements.refreshSnapshot(target); + await entitlements.getSnapshot(context); + 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(), + store: makeDurableStore(), + refreshOnMiss: "background", + onSnapshotRefreshed, + }); + const target = { mode: "customer", customerId: "customer-1" } as const; + + await expect( + entitlements.getBooleanValue("sso", false, context), + ).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(context)).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, + store: createMemoryEntitlementsCache(), + refreshOnMiss: "background", + onSnapshotRefreshed, + }); + + await expect( + entitlements.getBooleanValue("sso", false, context), + ).resolves.toEqual({ + value: false, + reason: "STALE", + flagMetadata: { snapshotPending: true }, + }); + + await vi.waitFor(() => + expect(onSnapshotRefreshed).toHaveBeenCalledWith( + expect.objectContaining({ trigger: "request" }), + ), + ); + await expect( + entitlements.getBooleanValue("sso", false, context), + ).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, + store: makeDurableStore(), + snapshotTtlMs: 1, + refreshOnMiss: "background", + }); + await entitlements.refreshSnapshot({ + mode: "customer", + customerId: "customer-1", + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + + await expect(entitlements.getSnapshot(context)).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", + refreshBackoffMs: 60_000, + onError, + }); + + await entitlements.getBooleanValue("sso", false, context); + await vi.waitFor(() => + expect(onError).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ operation: "refresh" }), + ), + ); + await entitlements.getBooleanValue("sso", false, context); + + expect(customerRequest).toHaveBeenCalledTimes(1); + }); +}); + +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 () => context); + const handler = createEntitlementsRelayHandler({ + entitlements, + resolveContext, + }); + + const spoofed = await handler( + new Request( + "https://example.com/api/entitlements?chargebeeCustomerId=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 () => context); + 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/openfeature/test/shared.test.ts b/packages/entitlements/test/shared.test.ts similarity index 100% rename from packages/openfeature/test/shared.test.ts rename to packages/entitlements/test/shared.test.ts diff --git a/packages/entitlements/test/web.test.ts b/packages/entitlements/test/web.test.ts new file mode 100644 index 0000000..a2d0ee9 --- /dev/null +++ b/packages/entitlements/test/web.test.ts @@ -0,0 +1,109 @@ +import { createEntitlementsSnapshot } from "../src/shared"; +import { ChargebeeEntitlementsWebClient } from "../src/web"; + +describe("ChargebeeEntitlementsWebClient", () => { + it("loads a relay snapshot and evaluates synchronously", async () => { + const snapshot = createEntitlementsSnapshot( + "customer", + [{ 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.getBooleanValue("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( + "customer", + [{ 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.getBooleanValue("sso", false)).toMatchObject({ + value: false, + reason: "STALE", + }); + expect(onStale).toHaveBeenCalledTimes(1); + }); + + it("refreshes its snapshot on reset", async () => { + const first = createEntitlementsSnapshot( + "customer", + [{ featureId: "sso", value: "false", isEnabled: true }], + 60_000, + ); + const second = createEntitlementsSnapshot( + "customer", + [{ 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.getBooleanValue("sso", true).value).toBe(false); + await client.reset(); + expect(client.getBooleanValue("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( + "customer", + [{ 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.getBooleanValue("sso", false)).toMatchObject({ + value: false, + errorCode: "PROVIDER_NOT_READY", + }); + }); +}); 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 index e620e5f..7e918ba 100644 --- a/packages/openfeature/CHANGELOG.md +++ b/packages/openfeature/CHANGELOG.md @@ -2,6 +2,28 @@ ## 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. - Replaced the tiered cache with a `cache` slot in front of a durable `store` slot on the server provider; both accept any `EntitlementsCache`. - Added `refreshOnMiss: "background"` so a missing snapshot resolves to caller diff --git a/packages/openfeature/README.md b/packages/openfeature/README.md index 11fd63d..a2171c4 100644 --- a/packages/openfeature/README.md +++ b/packages/openfeature/README.md @@ -1,16 +1,21 @@ # Chargebee OpenFeature provider -`@chargebee/openfeature` exposes Chargebee Entitlements through the OpenFeature -server and web SDKs. It supports customer-level consolidated entitlements, -subscription-level entitlements, a shared cache in front of a durable snapshot -store, and an authenticated browser relay for Next.js 16. +`@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 -Install only the OpenFeature SDKs used by your application: - ```sh -pnpm add @chargebee/openfeature chargebee @openfeature/server-sdk +pnpm add @chargebee/openfeature @chargebee/entitlements chargebee @openfeature/server-sdk pnpm add @openfeature/web-sdk ``` @@ -18,8 +23,9 @@ For React client components, also install `@openfeature/react-sdk`. ## Server provider -Pass an initialized Chargebee client to the provider. Chargebee credentials -remain entirely in server code. +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"; @@ -52,197 +58,44 @@ const enabled = await OpenFeature.getClient().getBooleanValue( ); ``` -For subscription-scoped evaluation: - -```ts -const seats = await OpenFeature.getClient().getNumberValue( - "licensed-seats", - 0, - { - targetingKey: session.user.id, - chargebeeEvaluationMode: "subscription", - chargebeeSubscriptionId: subscription.id, - }, -); -``` - -You can replace the built-in context keys with `resolveTarget(context)` when -your application has a different context model. - -## Entitlement mapping - -| Chargebee value | OpenFeature evaluation | -| --- | --- | -| Switch (`true`, `false`, or `available`) | Boolean | -| Custom level | String | -| Quantity or range | Number | -| `unlimited` | `Number.POSITIVE_INFINITY` with `unlimited` metadata | -| Full sanitized entitlement | Object | - -Disabled or expired entitlements return the caller's default with the -`DISABLED` reason. Missing features return `FLAG_NOT_FOUND`; invalid value -types return `TYPE_MISMATCH`. - -## Snapshot resolution - -The provider 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. It absorbs the many - entitlement checks a single authenticated request makes. -2. `store` — 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. +### Sharing a `ChargebeeEntitlements` instance -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 -``` +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 { createRedisEntitlementsCache } from "@chargebee/openfeature/cache"; -import Redis from "ioredis"; - -const redis = new Redis(process.env.REDIS_URL); -const cache = createRedisEntitlementsCache(redis, { ttlMs: 60_000 }); +import { ChargebeeEntitlements } from "@chargebee/entitlements/server"; +import { createEntitlementsRelayHandler } from "@chargebee/entitlements/nextjs"; +import { ChargebeeEntitlementsProvider } from "@chargebee/openfeature/server"; -export const entitlementsProvider = new ChargebeeEntitlementsProvider({ +export const entitlements = new ChargebeeEntitlements({ chargebeeClient: chargebee, cache, store: postgresSnapshotStore, - snapshotTtlMs: 24 * 60 * 60_000, - 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/openfeature/cache"; -import { parseSerializedEntitlementsSnapshot, serializeEntitlementsSnapshot } from "@chargebee/openfeature"; - -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 provider's `cacheTtlMs` overrides it per write -if you would rather configure expiry alongside the provider. 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 -provider 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 -`refreshBackoffMs`. - -Refresh the snapshot after processing relevant Chargebee webhooks, and remove -it when the target no longer exists: - -```ts -await entitlementsProvider.refreshSnapshot({ mode: "subscription", subscriptionId }); -await entitlementsProvider.deleteSnapshot({ mode: "customer", 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 -`evictCachedSnapshot` on its own when you want that fall-through without -fetching, and `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. - -### 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 provider 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 provider = new ChargebeeEntitlementsProvider({ - chargebeeClient: chargebee, - cache, - store: postgresSnapshotStore, - refreshOnMiss: "background", - onSnapshotRefreshed: ({ target, trigger }) => { - if (trigger === "request") notifySubscriptionReady(target); - }, - onError: (error, { operation, target }) => { - logger.error({ error, operation, target }, "entitlement snapshot"); - }, +export const entitlementsProvider = new ChargebeeEntitlementsProvider({ + entitlements, }); -``` -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. +// app/api/entitlements/route.ts +export const GET = createEntitlementsRelayHandler({ entitlements, resolveContext }); +``` -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. +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. - -Create an App Router route: - -```ts -// app/api/entitlements/route.ts -import { createEntitlementsRelayHandler } from "@chargebee/openfeature/nextjs"; -import { entitlementsProvider } from "@/lib/entitlements"; -import { getSession } from "@/lib/auth"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export const GET = createEntitlementsRelayHandler({ - provider: entitlementsProvider, - resolveContext: async (request) => { - const session = await getSession(request); - if (!session?.user.chargebeeCustomerId) return null; - - return { - targetingKey: session.user.id, - chargebeeCustomerId: session.user.chargebeeCustomerId, - }; - }, -}); -``` - -`resolveContext` must derive billing identity from the authenticated server -session. The relay rejects Chargebee identity query parameters, emits -`private, no-store` responses, and never returns customer IDs, subscription -IDs, or credentials. +sanitized snapshot from an authenticated application endpoint, served by +`@chargebee/entitlements/nextjs` (see its README for the route handler). Register the browser provider: @@ -266,23 +119,16 @@ export function FeatureProvider({ children }: { children: React.ReactNode }) { } ``` -The browser provider fetches with `cache: "no-store"` and same-origin -credentials, stores the snapshot in memory, refreshes it when OpenFeature -context changes, and performs synchronous evaluations. Expired snapshots fail -closed to caller defaults. - -Browser billing identity always comes from `resolveContext` on the server, not -from browser OpenFeature context. 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. +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 -- Customer mode uses `consolidate_entitlements=true` by default. -- Chargebee pagination is followed with a page size of 100. -- Without a `store`, 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 `OpenFeature.close()` during long-lived process shutdown. +- 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 index c3b49d0..5e18a26 100644 --- a/packages/openfeature/package.json +++ b/packages/openfeature/package.json @@ -15,8 +15,7 @@ "chargebee", "openfeature", "entitlements", - "feature-flags", - "nextjs" + "feature-flags" ], "engines": { "node": ">=22" @@ -30,11 +29,6 @@ "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", @@ -44,11 +38,6 @@ "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": { @@ -56,17 +45,11 @@ "*": [ "./dist/index.d.mts" ], - "cache": [ - "./dist/cache.d.mts" - ], "server": [ "./dist/server.d.mts" ], "web": [ "./dist/web.d.mts" - ], - "nextjs": [ - "./dist/nextjs.d.mts" ] } }, @@ -79,7 +62,7 @@ "scripts": { "build": "tsdown", "dev": "tsdown --watch", - "typecheck": "tsc --project tsconfig.json", + "typecheck": "tsc --build tsconfig.json", "test": "vitest run", "test:watch": "vitest", "coverage": "vitest run --coverage", @@ -90,8 +73,7 @@ "access": "public" }, "dependencies": { - "server-only": "^0.0.1", - "zod": "^4.4.3" + "@chargebee/entitlements": "workspace:*" }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", @@ -100,8 +82,6 @@ "@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", @@ -109,10 +89,7 @@ }, "peerDependencies": { "@openfeature/server-sdk": "^1.23.0", - "@openfeature/web-sdk": "^1.10.0", - "chargebee": "^3.30.0", - "ioredis": "^6.0.0", - "next": "^16.0.0" + "@openfeature/web-sdk": "^1.10.0" }, "peerDependenciesMeta": { "@openfeature/server-sdk": { @@ -120,15 +97,6 @@ }, "@openfeature/web-sdk": { "optional": true - }, - "chargebee": { - "optional": true - }, - "ioredis": { - "optional": true - }, - "next": { - "optional": true } } } diff --git a/packages/openfeature/src/index.ts b/packages/openfeature/src/index.ts index 83a947e..f741d4e 100644 --- a/packages/openfeature/src/index.ts +++ b/packages/openfeature/src/index.ts @@ -4,8 +4,9 @@ export type { ChargebeeEvaluationMode, ChargebeeTarget, EvaluationContextLike, + Logger, SnapshotSource, -} from "./shared"; +} from "@chargebee/entitlements"; export { CHARGEBEE_CONTEXT_KEYS, createEntitlementsSnapshot, @@ -14,4 +15,4 @@ export { parseEntitlementsSnapshot, parseSerializedEntitlementsSnapshot, serializeEntitlementsSnapshot, -} from "./shared"; +} from "@chargebee/entitlements"; diff --git a/packages/openfeature/src/server/index.ts b/packages/openfeature/src/server/index.ts index d8e7f72..6c8b211 100644 --- a/packages/openfeature/src/server/index.ts +++ b/packages/openfeature/src/server/index.ts @@ -1,16 +1,4 @@ export { - type ChargebeeEntitlementsClient, ChargebeeEntitlementsProvider, type ChargebeeEntitlementsProviderOptions, - type EntitlementsSnapshotResult, - type RefreshOnMiss, - type SnapshotErrorInfo, - type SnapshotOperation, - SnapshotPendingError, - type SnapshotRefreshedEvent, } from "./provider"; -export { - type CreateEntitlementsRelayHandlerOptions, - createEntitlementsRelayHandler, - type EntitlementsRelayHandler, -} from "./relay"; diff --git a/packages/openfeature/src/server/provider.ts b/packages/openfeature/src/server/provider.ts index be88399..f58f3f3 100644 --- a/packages/openfeature/src/server/provider.ts +++ b/packages/openfeature/src/server/provider.ts @@ -1,3 +1,8 @@ +import { toResolutionDetails } from "@chargebee/entitlements"; +import { + ChargebeeEntitlements, + type ChargebeeEntitlementsOptions, +} from "@chargebee/entitlements/server"; import type { ErrorCode, EvaluationContext, @@ -6,520 +11,99 @@ import type { Provider, ResolutionDetails, } from "@openfeature/server-sdk"; -import { createEntitlementsCacheKey, type EntitlementsStorage } from "../cache"; -import { - type ChargebeeEntitlementsSnapshot, - type ChargebeeEvaluationMode, - type ChargebeeTarget, - createEntitlementsSnapshot, - type EntitlementResolution, - errorResolution, - getTargetFromContext, - isSnapshotExpired, - resolveBooleanEntitlement, - resolveNumberEntitlement, - resolveObjectEntitlement, - resolveStringEntitlement, - type SnapshotSource, - toResolutionDetails, -} from "../shared"; -import { - type ChargebeeEntitlementsClient, - ChargebeeEntitlementsLoader, -} from "./loader"; - -/** - * 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 the caller can fall back to its own defaults. - */ -export type RefreshOnMiss = "blocking" | "background"; - -export 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 ChargebeeEntitlementsProviderOptions { - chargebeeClient: ChargebeeEntitlementsClient; - defaultMode?: ChargebeeEvaluationMode; - consolidateCustomerEntitlements?: boolean; - resolveTarget?: (context: EvaluationContext) => ChargebeeTarget; - /** Fast shared cache (Redis or similar) read before the store. */ - cache?: EntitlementsStorage; - /** Cache expiry. Defaults to whatever the cache implementation applies. */ - cacheTtlMs?: number; - /** Durable snapshot store treated as the source of truth. */ - store?: EntitlementsStorage; - cacheNamespace?: string; - snapshotTtlMs?: number; - refreshOnMiss?: RefreshOnMiss; - /** Minimum gap between background refresh attempts after a failure. */ - refreshBackoffMs?: number; - onSnapshotRefreshed?: (event: SnapshotRefreshedEvent) => void; - onError?: (error: unknown, info: SnapshotErrorInfo) => void; - pageSize?: number; - maxPages?: number; -} - -export interface EntitlementsSnapshotResult { - snapshot: ChargebeeEntitlementsSnapshot; - source: Exclude; -} - -class InvalidEntitlementContextError extends Error {} +export type ChargebeeEntitlementsProviderOptions = + | ChargebeeEntitlementsOptions + | { entitlements: ChargebeeEntitlements }; /** - * Thrown when no snapshot is available locally and the provider was configured - * to refresh in the background instead of blocking on Chargebee. + * 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 SnapshotPendingError extends Error { - readonly name = "SnapshotPendingError"; - - constructor(readonly target: ChargebeeTarget) { - super("Chargebee entitlement snapshot is still loading"); - } -} - export class ChargebeeEntitlementsProvider implements Provider { readonly metadata = { name: "Chargebee Entitlements" } as const; readonly runsOn = "server" as const; - - private readonly defaultMode: ChargebeeEvaluationMode; - private readonly consolidateCustomerEntitlements: boolean; - private readonly resolveTargetOption?: ( - context: EvaluationContext, - ) => ChargebeeTarget; - private readonly cache?: EntitlementsStorage; - private readonly cacheTtlMs?: number; - private readonly store?: EntitlementsStorage; - private readonly cacheNamespace?: string; - private readonly snapshotTtlMs: number; - private readonly refreshOnMiss: RefreshOnMiss; - 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(); + readonly entitlements: ChargebeeEntitlements; constructor(options: ChargebeeEntitlementsProviderOptions) { - if (!options?.chargebeeClient) - throw new Error("chargebeeClient is required"); - - this.defaultMode = options.defaultMode ?? "customer"; - this.consolidateCustomerEntitlements = - options.consolidateCustomerEntitlements ?? true; - this.resolveTargetOption = options.resolveTarget; - this.cache = options.cache; - this.cacheTtlMs = options.cacheTtlMs; - this.store = options.store; - this.cacheNamespace = options.cacheNamespace; - this.snapshotTtlMs = options.snapshotTtlMs ?? 300_000; - this.refreshOnMiss = options.refreshOnMiss ?? "blocking"; - this.refreshBackoffMs = options.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.pageSize ?? 100, - maxPages: options.maxPages ?? 50, - }); + this.entitlements = + "entitlements" in options + ? options.entitlements + : new ChargebeeEntitlements(options); } - async onClose(): Promise { - for (const request of this.inFlight.values()) request.cancelled = true; - this.inFlight.clear(); - this.failedAt.clear(); + onClose(): Promise { + return this.entitlements.close(); } - resolveBooleanEvaluation( + async resolveBooleanEvaluation( flagKey: string, defaultValue: boolean, context: EvaluationContext, logger: Logger, ): Promise> { - return this.evaluate(defaultValue, context, logger, (result) => - resolveBooleanEntitlement( - result.snapshot, + return toResolutionDetails( + await this.entitlements.getBooleanValue( flagKey, defaultValue, - result.source, + context, + logger, ), ); } - resolveStringEvaluation( + async resolveStringEvaluation( flagKey: string, defaultValue: string, context: EvaluationContext, logger: Logger, ): Promise> { - return this.evaluate(defaultValue, context, logger, (result) => - resolveStringEntitlement( - result.snapshot, + return toResolutionDetails( + await this.entitlements.getStringValue( flagKey, defaultValue, - result.source, + context, + logger, ), ); } - resolveNumberEvaluation( + async resolveNumberEvaluation( flagKey: string, defaultValue: number, context: EvaluationContext, logger: Logger, ): Promise> { - return this.evaluate(defaultValue, context, logger, (result) => - resolveNumberEntitlement( - result.snapshot, + return toResolutionDetails( + await this.entitlements.getNumberValue( flagKey, defaultValue, - result.source, + context, + logger, ), ); } - resolveObjectEvaluation( + async resolveObjectEvaluation( flagKey: string, defaultValue: T, context: EvaluationContext, logger: Logger, ): Promise> { - return this.evaluate(defaultValue, context, logger, (result) => - resolveObjectEntitlement( - result.snapshot, + return toResolutionDetails( + await this.entitlements.getObjectValue( flagKey, defaultValue, - result.source, - ), - ); - } - - /** - * 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( - context: EvaluationContext, - logger?: Logger, - ): Promise { - const target = this.resolveTarget(context); - const key = this.cacheKey(target); - - const cached = await this.read(this.cache, key, target, "cache-read"); - if (cached) { - this.refreshIfExpired(cached, target, key, logger); - return { snapshot: cached, source: "cache" }; - } - - const stored = await this.read(this.store, key, target, "store-read"); - if (stored) { - await this.write(this.cache, key, stored, target, "cache-write"); - this.refreshIfExpired(stored, target, key, logger); - return { snapshot: stored, source: "store" }; - } - - if (this.refreshOnMiss === "background") { - this.scheduleRefresh(target, key, logger); - throw new SnapshotPendingError(target); - } - - return { - snapshot: await this.fetchAndPersistSnapshot( - target, - key, - "request", - logger, - ), - 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, - logger?: Logger, - ): Promise { - const key = this.cacheKey(target); - // 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, target); - this.cancelInFlight(key); - return { - snapshot: await this.fetchAndPersistSnapshot( - target, - key, - "explicit", + context, logger, ), - source: "api", - }; - } - - /** Writes a snapshot assembled elsewhere, e.g. from a webhook payload. */ - async writeSnapshot( - target: ChargebeeTarget, - snapshot: ChargebeeEntitlementsSnapshot, - ): Promise { - if (snapshot.targetMode !== target.mode) { - throw new Error( - `Snapshot target mode ${snapshot.targetMode} does not match ${target.mode}`, - ); - } - const key = this.cacheKey(target); - this.cancelInFlight(key); - await this.persist(key, snapshot, target); - } - - /** Removes the snapshot from both the cache and the store. */ - async deleteSnapshot(target: ChargebeeTarget): Promise { - const key = this.cacheKey(target); - this.cancelInFlight(key); - await this.evictCache(key, target); - await this.store?.delete(key); - } - - /** Drops the cached copy, leaving the store as the next read's source. */ - async evictCachedSnapshot(target: ChargebeeTarget): Promise { - await this.evictCache(this.cacheKey(target), target); - } - - async getRelaySnapshot( - context: EvaluationContext, - ttlMs = 60_000, - logger?: Logger, - ): Promise { - const { snapshot } = await this.getSnapshot(context, logger); - return { - ...snapshot, - expiresAt: new Date( - Math.min(Date.parse(snapshot.expiresAt), Date.now() + ttlMs), - ).toISOString(), - }; - } - - private async evaluate( - defaultValue: T, - context: EvaluationContext, - logger: Logger, - resolve: (result: EntitlementsSnapshotResult) => EntitlementResolution, - ): Promise> { - try { - return toResolutionDetails( - resolve(await this.getSnapshot(context, logger)), - ); - } catch (error) { - if (error instanceof SnapshotPendingError) { - return { - value: defaultValue, - reason: "STALE", - flagMetadata: { snapshotPending: true }, - }; - } - return toResolutionDetails( - errorResolution( - defaultValue, - error instanceof InvalidEntitlementContextError - ? "INVALID_CONTEXT" - : "GENERAL", - error instanceof Error - ? error.message - : "Unable to evaluate Chargebee entitlement", - ), - ); - } - } - - private async read( - storage: EntitlementsStorage | undefined, - key: string, - target: ChargebeeTarget, - operation: Extract, - ): Promise { - if (!storage) return undefined; - try { - return await storage.get(key); - } catch (error) { - // A storage outage degrades to the next link in the chain. - this.onError?.(error, { operation, target }); - return undefined; - } - } - - private async write( - storage: EntitlementsStorage | undefined, - key: string, - snapshot: ChargebeeEntitlementsSnapshot, - target: ChargebeeTarget, - operation: Extract, - ): Promise { - if (!storage) return; - try { - await storage.set( - key, - snapshot, - operation === "cache-write" ? this.cacheTtlMs : this.snapshotTtlMs, - ); - } catch (error) { - this.onError?.(error, { operation, target }); - } - } - - private async evictCache( - key: string, - target: ChargebeeTarget, - ): Promise { - if (!this.cache) return; - try { - await this.cache.delete(key); - } catch (error) { - this.onError?.(error, { operation: "cache-delete", target }); - } - } - - /** - * 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, - logger?: Logger, - ): void { - if (isSnapshotExpired(snapshot)) this.scheduleRefresh(target, key, logger); - } - - private scheduleRefresh( - target: ChargebeeTarget, - key: string, - logger?: Logger, - ): void { - const failedAt = this.failedAt.get(key); - if (failedAt !== undefined && Date.now() - failedAt < this.refreshBackoffMs) - return; - - void this.fetchAndPersistSnapshot(target, key, "request", logger).catch( - () => { - // Reported through onError inside fetchAndPersistSnapshot. - }, ); } - - private fetchAndPersistSnapshot( - target: ChargebeeTarget, - key: string, - trigger: SnapshotRefreshedEvent["trigger"], - logger?: Logger, - ): 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( - target.mode, - await this.loader.load(target, logger), - 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 store is the source of truth, so its write failures propagate. */ - private async persist( - key: string, - snapshot: ChargebeeEntitlementsSnapshot, - target: ChargebeeTarget, - ): Promise { - if (this.store) await this.store.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(context: EvaluationContext): ChargebeeTarget { - try { - return this.resolveTargetOption - ? this.resolveTargetOption(context) - : getTargetFromContext(context, this.defaultMode); - } catch (error) { - throw new InvalidEntitlementContextError( - error instanceof Error ? error.message : "Invalid evaluation context", - ); - } - } - - private cacheKey(target: ChargebeeTarget): string { - return createEntitlementsCacheKey(target, { - namespace: this.cacheNamespace, - consolidateCustomerEntitlements: this.consolidateCustomerEntitlements, - }); - } } - -export type { ChargebeeEntitlementsClient }; diff --git a/packages/openfeature/src/web/provider.ts b/packages/openfeature/src/web/provider.ts index 9da4717..2725cb0 100644 --- a/packages/openfeature/src/web/provider.ts +++ b/packages/openfeature/src/web/provider.ts @@ -1,3 +1,8 @@ +import { toResolutionDetails } from "@chargebee/entitlements"; +import { + ChargebeeEntitlementsWebClient, + type ChargebeeEntitlementsWebClientOptions, +} from "@chargebee/entitlements/web"; import { type ErrorCode, type EvaluationContext, @@ -8,91 +13,57 @@ import { ProviderEvents, type ResolutionDetails, } from "@openfeature/web-sdk"; -import { - type ChargebeeEntitlementsSnapshot, - type EntitlementResolution, - errorResolution, - isSnapshotExpired, - parseEntitlementsSnapshot, - resolveBooleanEntitlement, - resolveNumberEntitlement, - resolveObjectEntitlement, - resolveStringEntitlement, - toResolutionDetails, -} from "../shared"; - -export interface ChargebeeEntitlementsWebProviderOptions { - relayUrl: string | URL; - fetchImplementation?: typeof fetch; - credentials?: RequestCredentials; - requestHeaders?: HeadersInit; -} - -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]), - ); +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(); - - private readonly relayUrl: string; - private readonly fetchImplementation: typeof fetch; - private readonly credentials: RequestCredentials; - private readonly requestHeaders?: HeadersInit; - private snapshot?: ChargebeeEntitlementsSnapshot; - private staleEventEmitted = false; - private closed = false; - private refreshInProgress = false; + readonly client: ChargebeeEntitlementsWebClient; constructor(options: ChargebeeEntitlementsWebProviderOptions) { - 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.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 }); + }, + }); } - async initialize(): Promise { - this.closed = false; - await this.loadSnapshot(false); + initialize(): Promise { + return this.client.initialize(); } - async onContextChange( + onContextChange( _oldContext: EvaluationContext, _newContext: EvaluationContext, ): Promise { - // A new session may represent a different billing subject. - this.snapshot = undefined; - this.staleEventEmitted = false; - this.refreshInProgress = false; - await this.refreshSnapshot(); - } - - async onClose(): Promise { - this.closed = true; - this.snapshot = undefined; - this.staleEventEmitted = false; - this.refreshInProgress = false; + return this.client.reset(); } - refreshSnapshot(): Promise { - return this.loadSnapshot(true); + onClose(): Promise { + return this.client.close(); } resolveBooleanEvaluation( @@ -101,8 +72,8 @@ export class ChargebeeEntitlementsWebProvider implements Provider { _context: EvaluationContext, _logger: Logger, ): ResolutionDetails { - return this.evaluate(defaultValue, (snapshot) => - resolveBooleanEntitlement(snapshot, flagKey, defaultValue, "relay"), + return toResolutionDetails( + this.client.getBooleanValue(flagKey, defaultValue), ); } @@ -112,8 +83,8 @@ export class ChargebeeEntitlementsWebProvider implements Provider { _context: EvaluationContext, _logger: Logger, ): ResolutionDetails { - return this.evaluate(defaultValue, (snapshot) => - resolveStringEntitlement(snapshot, flagKey, defaultValue, "relay"), + return toResolutionDetails( + this.client.getStringValue(flagKey, defaultValue), ); } @@ -123,8 +94,8 @@ export class ChargebeeEntitlementsWebProvider implements Provider { _context: EvaluationContext, _logger: Logger, ): ResolutionDetails { - return this.evaluate(defaultValue, (snapshot) => - resolveNumberEntitlement(snapshot, flagKey, defaultValue, "relay"), + return toResolutionDetails( + this.client.getNumberValue(flagKey, defaultValue), ); } @@ -134,94 +105,8 @@ export class ChargebeeEntitlementsWebProvider implements Provider { _context: EvaluationContext, _logger: Logger, ): ResolutionDetails { - return this.evaluate(defaultValue, (snapshot) => - resolveObjectEntitlement(snapshot, flagKey, defaultValue, "relay"), + return toResolutionDetails( + this.client.getObjectValue(flagKey, defaultValue), ); } - - private evaluate( - defaultValue: T, - resolve: ( - snapshot: ChargebeeEntitlementsSnapshot, - ) => EntitlementResolution, - ): ResolutionDetails { - const snapshot = this.getUsableSnapshot(defaultValue); - return "entitlements" in snapshot - ? toResolutionDetails(resolve(snapshot)) - : snapshot; - } - - private getUsableSnapshot( - defaultValue: T, - ): ChargebeeEntitlementsSnapshot | ResolutionDetails { - if (!this.snapshot) { - return toResolutionDetails( - errorResolution( - defaultValue, - "PROVIDER_NOT_READY", - "Chargebee entitlement snapshot is not loaded", - ), - ); - } - - if (!isSnapshotExpired(this.snapshot)) return this.snapshot; - - if (!this.staleEventEmitted) { - this.events.emit(ProviderEvents.Stale, { - message: "Chargebee entitlement snapshot expired", - }); - 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 provider 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.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged }); - } - } catch (error) { - if (emitChange) { - this.events.emit(ProviderEvents.Error, { - message: - error instanceof Error - ? error.message - : "Unable to refresh Chargebee entitlements", - }); - } - throw error; - } - } } diff --git a/packages/openfeature/test/server.test.ts b/packages/openfeature/test/server.test.ts index 24882b1..31edafb 100644 --- a/packages/openfeature/test/server.test.ts +++ b/packages/openfeature/test/server.test.ts @@ -1,391 +1,82 @@ import { - type EvaluationContext, - OpenFeature, -} from "@openfeature/server-sdk"; -import type { CustomerEntitlement } from "chargebee"; -import { - createMemoryEntitlementsCache, - type EntitlementsStorage, -} from "../src/cache"; -import { + ChargebeeEntitlements, type ChargebeeEntitlementsClient, - ChargebeeEntitlementsProvider, - createEntitlementsRelayHandler, -} from "../src/server"; -import type { ChargebeeEntitlementsSnapshot } from "../src/shared"; +} from "@chargebee/entitlements/server"; +import { OpenFeature } from "@openfeature/server-sdk"; +import { ChargebeeEntitlementsProvider } from "../src/server"; -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: [] })); +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, - }, + customerEntitlement: { entitlementsForCustomer: customerRequest }, subscriptionEntitlement: { - subscriptionEntitlementsForSubscription: subscriptionRequest, + subscriptionEntitlementsForSubscription: vi.fn(async () => ({ + list: [], + })), }, } as unknown as ChargebeeEntitlementsClient; - return { client, customerRequest, subscriptionRequest }; + return { client, customerRequest }; } -const context: EvaluationContext = { +const context = { targetingKey: "app-user-1", chargebeeCustomerId: "customer-1", }; -/** A durable store keeps snapshots past `expiresAt`; the provider 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, - }, - }, - ], -}; - afterEach(async () => { await OpenFeature.clearProviders(); }); describe("ChargebeeEntitlementsProvider", () => { - 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, - }, - }, - ], - }, - ]); + it("constructs its own ChargebeeEntitlements from options and works through the OpenFeature server SDK", async () => { + const { client } = makeClient(); const provider = new ChargebeeEntitlementsProvider({ chargebeeClient: client, - cache: createMemoryEntitlementsCache(), - cacheTtlMs: 60_000, - }); - - const first = await provider.resolveBooleanEvaluation( - "sso", - false, - context, - console, - ); - const second = await provider.resolveNumberEvaluation( - "seats", - 0, - context, - console, - ); - - 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 provider = new ChargebeeEntitlementsProvider({ - chargebeeClient: { - customerEntitlement: { - entitlementsForCustomer: customerRequest, - }, - subscriptionEntitlement: { - subscriptionEntitlementsForSubscription: vi.fn(), - }, - } as unknown as ChargebeeEntitlementsClient, - }); - - const first = provider.getSnapshot(context); - const second = provider.getSnapshot(context); - 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 provider = new ChargebeeEntitlementsProvider({ - chargebeeClient: { - customerEntitlement: { - entitlementsForCustomer: customerRequest, - }, - subscriptionEntitlement: { - subscriptionEntitlementsForSubscription: vi.fn(), - }, - } as unknown as ChargebeeEntitlementsClient, }); - const first = provider.getSnapshot(context); - await started; - await provider.deleteSnapshot({ mode: "customer", customerId: "customer-1" }); - release?.(); - await first; - await provider.getSnapshot(context); + expect(provider.entitlements).toBeInstanceOf(ChargebeeEntitlements); + await OpenFeature.setProviderAndWait(provider); - expect(customerRequest).toHaveBeenCalledTimes(2); + await expect( + OpenFeature.getClient().getBooleanValue("sso", false, context), + ).resolves.toBe(true); }); - it("returns INVALID_CONTEXT without calling Chargebee", async () => { - const { client, customerRequest } = makeClient([]); - const provider = new ChargebeeEntitlementsProvider({ - chargebeeClient: client, - }); + 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, - { targetingKey: "app-user-1" }, - console, - ), - ).resolves.toMatchObject({ - value: false, - errorCode: "INVALID_CONTEXT", - }); - expect(customerRequest).not.toHaveBeenCalled(); + provider.resolveBooleanEvaluation("sso", false, context, console), + ).resolves.toMatchObject({ value: true, reason: "TARGETING_MATCH" }); }); - it("loads subscription-scoped entitlements", async () => { - const subscriptionRequest = vi.fn(async () => ({ + it("evaluates string, number, and object flags", async () => { + const customerRequest = vi.fn(async () => ({ list: [ { - subscription_entitlement: { - subscription_id: "subscription-1", + customer_entitlement: { + customer_id: "customer-1", feature_id: "seats", - feature_name: "Licensed seats", - feature_unit: "seat", - feature_type: "quantity", - value: "50", - is_overridden: true, + value: "10", is_enabled: true, }, }, ], })); - const provider = new ChargebeeEntitlementsProvider({ - chargebeeClient: { - customerEntitlement: { - entitlementsForCustomer: vi.fn(), - }, - subscriptionEntitlement: { - subscriptionEntitlementsForSubscription: subscriptionRequest, - }, - } as unknown as ChargebeeEntitlementsClient, - }); - - await expect( - provider.resolveNumberEvaluation( - "seats", - 0, - { - chargebeeEvaluationMode: "subscription", - chargebeeSubscriptionId: "subscription-1", - }, - console, - ), - ).resolves.toMatchObject({ - value: 50, - flagMetadata: { - chargebeeFeatureType: "quantity", - chargebeeOverridden: true, - }, - }); - expect(subscriptionRequest).toHaveBeenCalledWith( - "subscription-1", - expect.objectContaining({ limit: 100 }), - ); - }); - - 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 ChargebeeEntitlementsProvider({ - chargebeeClient: client, - store, - }).refreshSnapshot({ mode: "customer", customerId: "customer-1" }); - - const reader = new ChargebeeEntitlementsProvider({ - chargebeeClient: client, - cache, - store, - refreshOnMiss: "background", - }); - - await expect(reader.getSnapshot(context)).resolves.toMatchObject({ - source: "store", - }); - await expect(reader.getSnapshot(context)).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 provider = new ChargebeeEntitlementsProvider({ - chargebeeClient: client, - cache: { - get: vi.fn(async () => { - throw new Error("redis unavailable"); - }), - set: vi.fn(async () => undefined), - delete: vi.fn(async () => undefined), - }, - store, - onError, - }); - await provider.refreshSnapshot({ - mode: "customer", - customerId: "customer-1", - }); - - await expect( - provider.resolveBooleanEvaluation("sso", false, context, console), - ).resolves.toMatchObject({ - value: true, - reason: "CACHED", - flagMetadata: { cacheSource: "store" }, - }); - expect(onError).toHaveBeenCalledWith(expect.any(Error), { - operation: "cache-read", - target: { mode: "customer", 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 provider = new ChargebeeEntitlementsProvider({ - chargebeeClient: client, - cache, - store, - }); - const target = { mode: "customer", customerId: "customer-1" } as const; - - await provider.refreshSnapshot(target); - await provider.getSnapshot(context); - await provider.refreshSnapshot(target); - - expect(evict).toHaveBeenCalledTimes(2); - expect(evict).toHaveBeenCalledWith( - "chargebee:openfeature: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 provider = new ChargebeeEntitlementsProvider({ chargebeeClient: { customerEntitlement: { entitlementsForCustomer: customerRequest }, @@ -393,47 +84,24 @@ describe("ChargebeeEntitlementsProvider", () => { subscriptionEntitlementsForSubscription: vi.fn(), }, } as unknown as ChargebeeEntitlementsClient, - cache: createMemoryEntitlementsCache(), - store: makeDurableStore(), - refreshOnMiss: "background", - onSnapshotRefreshed, }); - const target = { mode: "customer", customerId: "customer-1" } as const; await expect( - provider.resolveBooleanEvaluation("sso", false, context, console), - ).resolves.toMatchObject({ reason: "STALE" }); - await vi.waitFor(() => expect(customerRequest).toHaveBeenCalledTimes(1)); - - const explicitRefresh = provider.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(provider.getSnapshot(context)).resolves.toMatchObject({ - snapshot: { - entitlements: { sso: expect.objectContaining({ value: "false" }) }, - }, - }); + provider.resolveNumberEvaluation("seats", 0, context, console), + ).resolves.toMatchObject({ value: 10 }); + await expect( + provider.resolveStringEvaluation("seats", "0", context, console), + ).resolves.toMatchObject({ value: "10" }); + await expect( + provider.resolveObjectEvaluation("seats", {}, context, console), + ).resolves.toMatchObject({ value: { featureId: "seats", value: "10" } }); }); - it("reports a pending snapshot and refreshes in the background", async () => { - const { client, customerRequest } = makeClient([ssoPage]); - const onSnapshotRefreshed = vi.fn(); + it("passes through STALE with snapshotPending metadata while a background refresh loads", async () => { + const { client } = makeClient(); const provider = new ChargebeeEntitlementsProvider({ chargebeeClient: client, - store: createMemoryEntitlementsCache(), refreshOnMiss: "background", - onSnapshotRefreshed, }); await expect( @@ -443,154 +111,33 @@ describe("ChargebeeEntitlementsProvider", () => { reason: "STALE", flagMetadata: { snapshotPending: true }, }); - - await vi.waitFor(() => - expect(onSnapshotRefreshed).toHaveBeenCalledWith( - expect.objectContaining({ trigger: "request" }), - ), - ); - await expect( - provider.resolveBooleanEvaluation("sso", false, context, console), - ).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]); + it("passes through INVALID_CONTEXT without calling Chargebee", async () => { + const { client, customerRequest } = makeClient(); const provider = new ChargebeeEntitlementsProvider({ chargebeeClient: client, - store: makeDurableStore(), - snapshotTtlMs: 1, - refreshOnMiss: "background", }); - await provider.refreshSnapshot({ - mode: "customer", - customerId: "customer-1", - }); - await new Promise((resolve) => setTimeout(resolve, 5)); - - await expect(provider.getSnapshot(context)).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 provider = new ChargebeeEntitlementsProvider({ - chargebeeClient: { - customerEntitlement: { entitlementsForCustomer: customerRequest }, - subscriptionEntitlement: { - subscriptionEntitlementsForSubscription: vi.fn(), - }, - } as unknown as ChargebeeEntitlementsClient, - refreshOnMiss: "background", - refreshBackoffMs: 60_000, - onError, - }); - - await provider.resolveBooleanEvaluation("sso", false, context, console); - await vi.waitFor(() => - expect(onError).toHaveBeenCalledWith( - expect.any(Error), - expect.objectContaining({ operation: "refresh" }), - ), - ); - await provider.resolveBooleanEvaluation("sso", false, context, console); - - expect(customerRequest).toHaveBeenCalledTimes(1); - }); - - it("works through the OpenFeature server SDK", async () => { - const { client } = makeClient([ - { - list: [ - { - customer_entitlement: { - customer_id: "customer-1", - feature_id: "sso", - value: "true", - is_enabled: true, - }, - }, - ], - }, - ]); - await OpenFeature.setProviderAndWait( - new ChargebeeEntitlementsProvider({ chargebeeClient: client }), - ); await expect( - OpenFeature.getClient().getBooleanValue("sso", false, context), - ).resolves.toBe(true); - }); -}); - -describe("entitlement relay", () => { - it("derives identity on the server and rejects spoofed IDs", async () => { - const { client } = makeClient([{ list: [] }]); - const provider = new ChargebeeEntitlementsProvider({ - chargebeeClient: client, - }); - const resolveContext = vi.fn(async () => context); - const handler = createEntitlementsRelayHandler({ - provider, - resolveContext, - }); - - const spoofed = await handler( - new Request( - "https://example.com/api/entitlements?chargebeeCustomerId=other", + provider.resolveBooleanEvaluation( + "sso", + false, + { targetingKey: "app-user-1" }, + console, ), - ); - 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"); + ).resolves.toMatchObject({ value: false, errorCode: "INVALID_CONTEXT" }); + expect(customerRequest).not.toHaveBeenCalled(); }); - it("returns 401 when the application session has no identity", async () => { - const { client } = makeClient([]); - const handler = createEntitlementsRelayHandler({ - provider: new ChargebeeEntitlementsProvider({ - chargebeeClient: client, - }), - resolveContext: async () => null, - }); + 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 }); - 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 () => context); - const handler = createEntitlementsRelayHandler({ - provider: new ChargebeeEntitlementsProvider({ - chargebeeClient: client, - }), - resolveContext, - }); + await provider.onClose(); - 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(); + expect(close).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/openfeature/test/web.test.ts b/packages/openfeature/test/web.test.ts index bc562b4..71f7b2e 100644 --- a/packages/openfeature/test/web.test.ts +++ b/packages/openfeature/test/web.test.ts @@ -1,5 +1,5 @@ -import { OpenFeature } from "@openfeature/web-sdk"; -import { createEntitlementsSnapshot } from "../src/shared"; +import { createEntitlementsSnapshot } from "@chargebee/entitlements"; +import { OpenFeature, ProviderEvents } from "@openfeature/web-sdk"; import { ChargebeeEntitlementsWebProvider } from "../src/web"; afterEach(async () => { @@ -31,7 +31,7 @@ describe("ChargebeeEntitlementsWebProvider", () => { ); }); - it("fails closed when the relay snapshot expires", async () => { + it("emits Stale and falls back to the default while the relay snapshot expires", async () => { const snapshot = createEntitlementsSnapshot( "customer", [{ featureId: "sso", value: "true", isEnabled: true }], @@ -42,17 +42,17 @@ describe("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, {}, console), - ).toMatchObject({ - value: false, - reason: "STALE", - }); + ).toMatchObject({ value: false, reason: "STALE" }); + expect(onStale).toHaveBeenCalledTimes(1); }); - it("refreshes its snapshot when context changes", async () => { + it("emits ConfigurationChanged and refreshes its snapshot on context change", async () => { const first = createEntitlementsSnapshot( "customer", [{ featureId: "sso", value: "false", isEnabled: true }], @@ -71,6 +71,11 @@ describe("ChargebeeEntitlementsWebProvider", () => { relayUrl: "/api/entitlements", fetchImplementation, }); + const onConfigurationChanged = vi.fn(); + provider.events.addHandler( + ProviderEvents.ConfigurationChanged, + onConfigurationChanged, + ); await provider.initialize(); expect( @@ -80,6 +85,9 @@ describe("ChargebeeEntitlementsWebProvider", () => { expect( provider.resolveBooleanEvaluation("sso", false, {}, console).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 () => { @@ -113,4 +121,19 @@ describe("ChargebeeEntitlementsWebProvider", () => { errorCode: "PROVIDER_NOT_READY", }); }); + + it("delegates onClose to the wrapped client", async () => { + const provider = new ChargebeeEntitlementsWebProvider({ + relayUrl: "/api/entitlements", + fetchImplementation: async () => + Response.json( + createEntitlementsSnapshot("customer", [], 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 index b6e3391..ef26f8b 100644 --- a/packages/openfeature/tsconfig.json +++ b/packages/openfeature/tsconfig.json @@ -17,8 +17,14 @@ "lib": ["esnext", "dom", "dom.iterable"], "types": ["node", "vitest/globals"], "outDir": "./dist", - "declarationDir": "./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 index bde64f3..7491c0f 100644 --- a/packages/openfeature/tsdown.config.ts +++ b/packages/openfeature/tsdown.config.ts @@ -5,19 +5,15 @@ export default defineConfig({ 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/entitlements", + "@chargebee/entitlements/server", + "@chargebee/entitlements/web", "@openfeature/server-sdk", "@openfeature/web-sdk", - "chargebee", - "ioredis", - "next", - "next/server", - "server-only", ], sourcemap: true, }); diff --git a/packages/openfeature/vitest.config.ts b/packages/openfeature/vitest.config.ts index e91c446..4e7b161 100644 --- a/packages/openfeature/vitest.config.ts +++ b/packages/openfeature/vitest.config.ts @@ -1,6 +1,13 @@ 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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c57440e..c372550 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,7 @@ 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 importers: @@ -98,6 +99,46 @@ importers: specifier: ^3.25.20 version: 3.25.20 + packages/entitlements: + dependencies: + server-only: + specifier: ^0.0.1 + version: 0.0.1 + zod: + specifier: ^4.4.3 + version: 4.4.3 + 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: @@ -129,12 +170,9 @@ importers: packages/openfeature: dependencies: - server-only: - specifier: ^0.0.1 - version: 0.0.1 - zod: - specifier: ^4.4.3 - version: 4.4.3 + '@chargebee/entitlements': + specifier: link:../entitlements + version: link:../entitlements devDependencies: '@arethetypeswrong/cli': specifier: ^0.18.5 @@ -154,12 +192,6 @@ importers: 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 @@ -3468,7 +3500,7 @@ snapshots: '@kwsites/file-exists@1.1.1(supports-color@7.2.0)': dependencies: - debug: 4.4.1(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -4034,7 +4066,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.1(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) http-errors: 2.0.0 iconv-lite: 0.6.3 on-finished: 2.4.1 @@ -4392,7 +4424,7 @@ snapshots: finalhandler@2.1.0(supports-color@7.2.0): dependencies: - debug: 4.4.1(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -5012,7 +5044,7 @@ snapshots: router@2.2.0(supports-color@7.2.0): dependencies: - debug: 4.4.1(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -5036,7 +5068,7 @@ snapshots: send@1.2.0(supports-color@7.2.0): dependencies: - debug: 4.4.1(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 36621e1..9aafd30 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ 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 From b63fc2269a4ed59fd7aec5c5549a948fb0a1aac8 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Thu, 13 Aug 2026 09:58:47 +1000 Subject: [PATCH 11/22] delete todo --- TODO.md | 35 ----------------------------------- 1 file changed, 35 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 8bcea1a..0000000 --- a/TODO.md +++ /dev/null @@ -1,35 +0,0 @@ -# Beta - -- [x] Pull in live data in the `apiPayload` method - -- [x] Optionally get the prefix (/chargebee) from the user/let them replace - -- [x] Set header for all API calls indicating the source tool (chargebee-init) and the framework (nextjs/express) - -- [x] Update cb-client-libs to accept a list of default headers for the client - -- [x] Cleanup creation of multiple clients for each middleware, make it a singleton - -- [x] Move existing readme to developer.md and add documentation for usage - -- [x] CI pipeline to at least build all the packages to maintain basic sanity - -# Nice to have - -- [ ] Write/append the required env variables to `.env` or `.env.example` - -- [ ] Automatically run the appropriate package manager by detecting which lockfile is present - -- [ ] Consolidate the initial checks (git/supported framework) into a single step and display the results to the user with an option of continuing if it's just a warning. This makes it very evident what is expected to run the tool - -- [ ] changesets/conventional commits for release - -- [ ] Read express/nextjs adapter version from package.json and pass to userAgentSuffix - -# Other topics discussed with KPS - -- [ ] Entitlements - -- [ ] Entitlements service sidecar?? - -- [ ] Better auth integration: what are the major features and how does the API interact? From 6d9e73199685b980c3e13bc0a404b26d5ad87c9a Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Fri, 21 Aug 2026 10:52:24 +1000 Subject: [PATCH 12/22] add feature class --- packages/entitlements/CHANGELOG.md | 6 + packages/entitlements/README.md | 58 +++++ packages/entitlements/src/index.ts | 9 + .../entitlements/src/server/entitlements.ts | 19 ++ packages/entitlements/src/server/index.ts | 11 + packages/entitlements/src/shared/feature.ts | 198 ++++++++++++++++++ packages/entitlements/src/shared/index.ts | 11 + packages/entitlements/test/feature.test.ts | 194 +++++++++++++++++ 8 files changed, 506 insertions(+) create mode 100644 packages/entitlements/src/shared/feature.ts create mode 100644 packages/entitlements/test/feature.test.ts diff --git a/packages/entitlements/CHANGELOG.md b/packages/entitlements/CHANGELOG.md index 77f1228..64d8130 100644 --- a/packages/entitlements/CHANGELOG.md +++ b/packages/entitlements/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Added a concise feature API. Declare a feature with + `new Feature(id, { type, defaultValue })` (or `entitlements.feature(...)`) and + fetch its typed value with a single `feature.get(target)`; `getDetails` + returns the full resolution. Standalone features resolve against the client + passed to `setDefaultEntitlements(client)`. The existing + `getBooleanValue`/`getNumberValue`/... methods are unchanged. - Initial extraction of the framework-agnostic Chargebee entitlements client from `@chargebee/openfeature`. `ChargebeeEntitlements` (server) and `ChargebeeEntitlementsWebClient` (web) can now be used directly, without an diff --git a/packages/entitlements/README.md b/packages/entitlements/README.md index 63b4954..1a71c07 100644 --- a/packages/entitlements/README.md +++ b/packages/entitlements/README.md @@ -67,6 +67,64 @@ const enabled = await entitlements.getBooleanValue("advanced-reports", false, { context keys with `resolveTarget(context)` when your application has a different context model. +## Concise feature API + +The `getBooleanValue`/`getNumberValue`/... methods are explicit but verbose to +repeat at every call site. Declare a feature once and fetch its value with a +single `get` call. The declared `type` sets the return type, and `get` returns +the resolved value directly rather than a full resolution object: + +```ts +import { Feature } from "@chargebee/entitlements"; + +// Declare the feature once, anywhere. +const licensedSeats = new Feature("licensed-seats", { + type: "number", + defaultValue: 0, +}); + +// Fetch the value for the current request's billing context. +const seats = await licensedSeats.get({ + mode: "customer", + customerId: ctx.user.chargebeeCustomerId, +}); // number +``` + +`get` accepts the same explicit `ChargebeeTarget` or context-shaped bag as the +underlying methods. `type` is one of `"boolean"`, `"string"`, `"number"`, or +`"object"`, and `defaultValue` must match it. + +A standalone `new Feature(...)` needs a client to evaluate against. Register one +once during start-up: + +```ts +import { setDefaultEntitlements } from "@chargebee/entitlements/server"; +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 licensedSeats = entitlements.feature("licensed-seats", { + type: "number", + defaultValue: 0, +}); +``` + +When you need the reason or Chargebee metadata behind a value, call +`getDetails`, which returns the same `EntitlementResolution` the explicit +methods do: + +```ts +const { value, reason, flagMetadata } = await licensedSeats.getDetails(target); +``` + +Both `get` and `getDetails` accept `{ client, logger }` to override the bound or +default client for a single call. + ## Entitlement mapping | Chargebee value | Resolved as | diff --git a/packages/entitlements/src/index.ts b/packages/entitlements/src/index.ts index 2e109e0..2cae3b0 100644 --- a/packages/entitlements/src/index.ts +++ b/packages/entitlements/src/index.ts @@ -5,17 +5,26 @@ export type { ChargebeeTarget, EntitlementErrorCode, EntitlementResolution, + EntitlementsEvaluator, EvaluationContextLike, + FeatureDefinition, + FeatureGetOptions, + FeatureTarget, + FeatureType, + FeatureTypeFor, Logger, SnapshotSource, } from "./shared"; export { CHARGEBEE_CONTEXT_KEYS, createEntitlementsSnapshot, + Feature, + getDefaultEntitlements, getTargetFromContext, isSnapshotExpired, parseEntitlementsSnapshot, parseSerializedEntitlementsSnapshot, serializeEntitlementsSnapshot, + setDefaultEntitlements, toResolutionDetails, } from "./shared"; diff --git a/packages/entitlements/src/server/entitlements.ts b/packages/entitlements/src/server/entitlements.ts index 9fb2e27..7ddf8a3 100644 --- a/packages/entitlements/src/server/entitlements.ts +++ b/packages/entitlements/src/server/entitlements.ts @@ -7,6 +7,8 @@ import { type EntitlementResolution, type EvaluationContextLike, errorResolution, + Feature, + type FeatureDefinition, getTargetFromContext, isSnapshotExpired, type Logger, @@ -235,6 +237,23 @@ export class ChargebeeEntitlements { ); } + /** + * Declares a feature bound to this client, so its value is fetched with a + * single concise call: + * + * ```ts + * const seats = entitlements.feature("licensed-seats", { + * type: "number", + * defaultValue: 0, + * }); + * + * const value = await seats.get({ mode: "customer", customerId }); + * ``` + */ + feature(featureId: string, definition: FeatureDefinition): Feature { + return new Feature(featureId, { ...definition, client: this }); + } + /** * Resolves a snapshot from the cache, then the store, then Chargebee. * Throws {@link SnapshotPendingError} when nothing is stored locally and diff --git a/packages/entitlements/src/server/index.ts b/packages/entitlements/src/server/index.ts index 57c886e..33c0040 100644 --- a/packages/entitlements/src/server/index.ts +++ b/packages/entitlements/src/server/index.ts @@ -1,3 +1,14 @@ +export { + type EntitlementsEvaluator, + Feature, + type FeatureDefinition, + type FeatureGetOptions, + type FeatureTarget, + type FeatureType, + type FeatureTypeFor, + getDefaultEntitlements, + setDefaultEntitlements, +} from "../shared"; export { ChargebeeEntitlements, type ChargebeeEntitlementsClient, diff --git a/packages/entitlements/src/shared/feature.ts b/packages/entitlements/src/shared/feature.ts new file mode 100644 index 0000000..f47315a --- /dev/null +++ b/packages/entitlements/src/shared/feature.ts @@ -0,0 +1,198 @@ +import type { + ChargebeeTarget, + EntitlementResolution, + EvaluationContextLike, + Logger, +} from "./types"; + +/** A Chargebee evaluation target, or a context bag it can be derived from. */ +export type FeatureTarget = ChargebeeTarget | EvaluationContextLike; + +/** + * The declared shape of a {@link Feature}'s value. It selects the evaluation + * method used and, through {@link FeatureTypeFor}, the value type `get` + * resolves to. + */ +export type FeatureType = "boolean" | "string" | "number" | "object"; + +/** Maps a resolved value type back to the {@link FeatureType} that declares it. */ +export type FeatureTypeFor = [T] extends [boolean] + ? "boolean" + : [T] extends [string] + ? "string" + : [T] extends [number] + ? "number" + : "object"; + +/** + * The subset of a Chargebee entitlements client a {@link Feature} evaluates + * against. The server `ChargebeeEntitlements` client satisfies it directly, so + * a feature can resolve values without holding a reference to the concrete + * class. + */ +export interface EntitlementsEvaluator { + getBooleanValue( + flagKey: string, + defaultValue: boolean, + target: FeatureTarget, + logger?: Logger, + ): Promise>; + getStringValue( + flagKey: string, + defaultValue: string, + target: FeatureTarget, + logger?: Logger, + ): Promise>; + getNumberValue( + flagKey: string, + defaultValue: number, + target: FeatureTarget, + logger?: Logger, + ): Promise>; + getObjectValue( + flagKey: string, + defaultValue: T, + target: FeatureTarget, + logger?: Logger, + ): Promise>; +} + +/** + * Declares a single feature. `type` and `defaultValue` must agree: declaring + * `type: "number"` requires a numeric `defaultValue`, which in turn types the + * value `get` resolves to. + */ +export interface FeatureDefinition { + type: FeatureTypeFor; + defaultValue: T; + /** + * Evaluates this feature instead of the client registered with + * {@link setDefaultEntitlements}. Prefer `entitlements.feature(...)` when a + * client is already in scope. + */ + client?: EntitlementsEvaluator; +} + +export interface FeatureGetOptions { + /** Overrides the feature's bound client and the default client for one call. */ + client?: EntitlementsEvaluator; + logger?: Logger; +} + +let defaultEvaluator: EntitlementsEvaluator | 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: EntitlementsEvaluator | undefined, +): void { + defaultEvaluator = client; +} + +/** Returns the client registered with {@link setDefaultEntitlements}, if any. */ +export function getDefaultEntitlements(): EntitlementsEvaluator | undefined { + return defaultEvaluator; +} + +/** + * A declared feature whose value is fetched with a single concise `get` call. + * + * ```ts + * const licensedSeats = new Feature("licensed-seats", { + * type: "number", + * defaultValue: 0, + * }); + * + * const seats = await licensedSeats.get({ + * mode: "customer", + * customerId: ctx.user.chargebeeCustomerId, + * }); + * ``` + * + * A standalone `new Feature(...)` resolves against the client registered with + * {@link setDefaultEntitlements}. Bind a specific client with + * `entitlements.feature(...)`, the `client` definition option, or the `client` + * option on `get`. + */ +export class Feature { + readonly featureId: string; + readonly type: FeatureType; + readonly defaultValue: T; + private readonly boundClient?: EntitlementsEvaluator; + + constructor(featureId: string, definition: FeatureDefinition) { + if (!featureId) throw new Error("featureId is required"); + this.featureId = featureId; + this.type = definition.type; + this.defaultValue = definition.defaultValue; + this.boundClient = definition.client; + } + + /** + * Resolves the feature's value for `target`, falling back to `defaultValue` + * when the feature is missing, disabled, or the snapshot is unavailable. Use + * {@link getDetails} when the reason or Chargebee metadata is needed. + */ + async get(target: FeatureTarget, options?: FeatureGetOptions): Promise { + return (await this.getDetails(target, options)).value; + } + + /** Like {@link get}, but returns the full resolution, not just the value. */ + getDetails( + target: FeatureTarget, + options?: FeatureGetOptions, + ): Promise> { + const client = options?.client ?? this.boundClient ?? defaultEvaluator; + if (!client) { + throw new Error( + `No Chargebee entitlements client is configured for feature "${this.featureId}". ` + + "Register one with setDefaultEntitlements(client), create the feature " + + "with entitlements.feature(...), or pass { client } to get().", + ); + } + + const { logger } = options ?? {}; + switch (this.type) { + case "boolean": + return client.getBooleanValue( + this.featureId, + this.defaultValue as boolean, + target, + logger, + ) as Promise>; + case "string": + return client.getStringValue( + this.featureId, + this.defaultValue as string, + target, + logger, + ) as Promise>; + case "number": + return client.getNumberValue( + this.featureId, + this.defaultValue as number, + target, + logger, + ) as Promise>; + default: + return client.getObjectValue( + this.featureId, + this.defaultValue, + target, + logger, + ); + } + } + + /** Returns a copy of this feature bound to `client`. */ + withClient(client: EntitlementsEvaluator): Feature { + return new Feature(this.featureId, { + type: this.type as FeatureTypeFor, + defaultValue: this.defaultValue, + client, + }); + } +} diff --git a/packages/entitlements/src/shared/index.ts b/packages/entitlements/src/shared/index.ts index 8fa0568..1a33bac 100644 --- a/packages/entitlements/src/shared/index.ts +++ b/packages/entitlements/src/shared/index.ts @@ -7,6 +7,17 @@ export { resolveStringEntitlement, toResolutionDetails, } from "./evaluation"; +export { + type EntitlementsEvaluator, + Feature, + type FeatureDefinition, + type FeatureGetOptions, + type FeatureTarget, + type FeatureType, + type FeatureTypeFor, + getDefaultEntitlements, + setDefaultEntitlements, +} from "./feature"; export { createEntitlementsSnapshot, isSnapshotExpired, diff --git a/packages/entitlements/test/feature.test.ts b/packages/entitlements/test/feature.test.ts new file mode 100644 index 0000000..e92b2f3 --- /dev/null +++ b/packages/entitlements/test/feature.test.ts @@ -0,0 +1,194 @@ +import type { CustomerEntitlement } from "chargebee"; +import { + ChargebeeEntitlements, + type ChargebeeEntitlementsClient, +} from "../src/server"; +import type { EntitlementResolution, FeatureTarget } from "../src/shared"; +import { + type EntitlementsEvaluator, + 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: FeatureTarget = { mode: "customer", 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", { + type: "number", + defaultValue: 0, + }); + + const seats = await licensedSeats.get(target); + + expectTypeOf(seats).toEqualTypeOf(); + expect(seats).toBe(25); + }); + + it("infers the value type from the declared feature type", async () => { + const entitlements = makeEntitlements(); + + const advancedReports = entitlements.feature("advanced-reports", { + type: "boolean", + defaultValue: false, + }); + const supportTier = entitlements.feature("support-tier", { + type: "string", + defaultValue: "basic", + }); + const seats = entitlements.feature("licensed-seats", { + type: "number", + defaultValue: 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("resolves object features to the declared shape", async () => { + const entitlements = makeEntitlements(); + const supportTier = entitlements.feature("support-tier", { + type: "object", + defaultValue: {} 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", { + type: "number", + defaultValue: 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", { + type: "number", + defaultValue: 7, + }); + + await expect(missing.get(target)).resolves.toBe(7); + await expect(missing.getDetails(target)).resolves.toMatchObject({ + value: 7, + errorCode: "FLAG_NOT_FOUND", + }); + }); + + it("prefers an explicit client over the bound and default clients", async () => { + const calls: string[] = []; + const stub = (name: string): EntitlementsEvaluator => ({ + getBooleanValue: async () => ({ value: false }), + getStringValue: async () => ({ value: "" }), + getObjectValue: async () => ({ value: undefined as T }), + getNumberValue: async () => { + calls.push(name); + return { value: name === "override" ? 99 : 1 }; + }, + }); + setDefaultEntitlements(stub("default")); + + const seats = new Feature("licensed-seats", { + type: "number", + defaultValue: 0, + client: stub("bound"), + }); + + await expect( + seats.get(target, { client: stub("override") }), + ).resolves.toBe(99); + expect(calls).toEqual(["override"]); + }); + + it("binds a copy to a client with withClient", async () => { + const entitlements = makeEntitlements(); + const seats = new Feature("licensed-seats", { + type: "number", + defaultValue: 0, + }).withClient(entitlements); + + 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", { + type: "number", + defaultValue: 0, + }); + + await expect(seats.get(target)).rejects.toThrow( + /No Chargebee entitlements client is configured/, + ); + }); +}); From e8e1839fc2e19084eb05037e43f95080f39391dc Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Fri, 21 Aug 2026 14:20:40 +1000 Subject: [PATCH 13/22] refactor --- packages/entitlements/CHANGELOG.md | 48 ++- packages/entitlements/README.md | 225 +++++++------ packages/entitlements/package.json | 5 +- packages/entitlements/src/cache/index.ts | 5 + packages/entitlements/src/cache/key.ts | 2 +- packages/entitlements/src/index.ts | 20 +- packages/entitlements/src/nextjs.ts | 7 +- .../entitlements/src/server/entitlements.ts | 313 ++++++------------ packages/entitlements/src/server/index.ts | 18 +- packages/entitlements/src/server/loader.ts | 105 +++--- packages/entitlements/src/server/relay.ts | 41 ++- packages/entitlements/src/shared/context.ts | 30 -- .../entitlements/src/shared/evaluation.ts | 234 +++++++------ packages/entitlements/src/shared/feature.ts | 197 +++-------- packages/entitlements/src/shared/index.ts | 38 +-- packages/entitlements/src/shared/snapshot.ts | 72 ++-- packages/entitlements/src/shared/target.ts | 28 ++ packages/entitlements/src/shared/types.ts | 23 +- packages/entitlements/src/web/client.ts | 64 +--- packages/entitlements/test/cache.test.ts | 1 - packages/entitlements/test/feature.test.ts | 124 ++++--- packages/entitlements/test/server.test.ts | 136 ++++---- packages/entitlements/test/shared.test.ts | 196 +++++++---- packages/entitlements/test/web.test.ts | 54 ++- packages/openfeature/CHANGELOG.md | 21 +- packages/openfeature/README.md | 12 +- packages/openfeature/src/index.ts | 15 +- packages/openfeature/src/resolution.ts | 17 + packages/openfeature/src/server/provider.ts | 72 ++-- packages/openfeature/src/web/provider.ts | 34 +- packages/openfeature/test/server.test.ts | 63 +++- packages/openfeature/test/web.test.ts | 30 +- pnpm-lock.yaml | 3 - 33 files changed, 1070 insertions(+), 1183 deletions(-) delete mode 100644 packages/entitlements/src/shared/context.ts create mode 100644 packages/entitlements/src/shared/target.ts create mode 100644 packages/openfeature/src/resolution.ts diff --git a/packages/entitlements/CHANGELOG.md b/packages/entitlements/CHANGELOG.md index 64d8130..f5d7b81 100644 --- a/packages/entitlements/CHANGELOG.md +++ b/packages/entitlements/CHANGELOG.md @@ -2,12 +2,48 @@ ## Unreleased -- Added a concise feature API. Declare a feature with - `new Feature(id, { type, defaultValue })` (or `entitlements.feature(...)`) and - fetch its typed value with a single `feature.get(target)`; `getDetails` - returns the full resolution. Standalone features resolve against the client - passed to `setDefaultEntitlements(client)`. The existing - `getBooleanValue`/`getNumberValue`/... methods are unchanged. +- 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 diff --git a/packages/entitlements/README.md b/packages/entitlements/README.md index 1a71c07..e95055b 100644 --- a/packages/entitlements/README.md +++ b/packages/entitlements/README.md @@ -1,12 +1,6 @@ # Chargebee Entitlements -`@chargebee/entitlements` resolves Chargebee entitlements — customer-level -consolidated entitlements or subscription-level entitlements — into typed -boolean/string/number/object values, with a shared cache in front of a -durable snapshot store, background refresh, and an authenticated browser -relay for Next.js 16. It has no dependency on any feature-flag SDK; use it -directly, or wrap it with an adapter such as -[`@chargebee/openfeature`](https://github.com/chargebee/js-framework-adapters/blob/main/packages/openfeature/README.md). +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 @@ -14,91 +8,98 @@ directly, or wrap it with an adapter such as pnpm add @chargebee/entitlements chargebee ``` -## Server client +## Usage -Pass an initialized Chargebee client to the client. Chargebee credentials -remain entirely in server code. +Declare a feature once, then fetch its value wherever you need it: ```ts -import Chargebee from "chargebee"; -import { ChargebeeEntitlements } from "@chargebee/entitlements/server"; +import { Feature } from "@chargebee/entitlements"; -const chargebee = new Chargebee({ - site: process.env.CHARGEBEE_SITE!, - apiKey: process.env.CHARGEBEE_API_KEY!, -}); +// 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 }); -export const entitlements = new ChargebeeEntitlements({ - chargebeeClient: chargebee, -}); ``` -Evaluate Chargebee feature IDs directly against a `ChargebeeTarget`: +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 -const enabled = await entitlements.getBooleanValue("advanced-reports", false, { - mode: "customer", - customerId: session.user.chargebeeCustomerId, -}); +// 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); ``` -For subscription-scoped evaluation: +When you need the reason or Chargebee metadata behind a value, call +`getDetails` instead of `get`: ```ts -const seats = await entitlements.getNumberValue("licensed-seats", 0, { - mode: "subscription", - subscriptionId: subscription.id, -}); +const { value, reason, flagMetadata } = await seats.getDetails(target); ``` -You can also pass a context-shaped bag instead of an explicit target — -useful when the same object already carries an application's subject ID and -Chargebee identity (e.g. an OpenFeature evaluation context, or your own -request context): +## 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 enabled = await entitlements.getBooleanValue("advanced-reports", false, { +const count = await seats.get({ targetingKey: session.user.id, - chargebeeCustomerId: session.user.chargebeeCustomerId, + customerId: session.user.chargebeeCustomerId, }); ``` -`targetingKey` is never assumed to be a Chargebee ID. Replace the built-in -context keys with `resolveTarget(context)` when your application has a -different context model. - -## Concise feature API +## Server client -The `getBooleanValue`/`getNumberValue`/... methods are explicit but verbose to -repeat at every call site. Declare a feature once and fetch its value with a -single `get` call. The declared `type` sets the return type, and `get` returns -the resolved value directly rather than a full resolution object: +Pass an initialized Chargebee client. Chargebee credentials remain entirely +in server code. ```ts -import { Feature } from "@chargebee/entitlements"; +import Chargebee from "chargebee"; +import { ChargebeeEntitlements } from "@chargebee/entitlements/server"; -// Declare the feature once, anywhere. -const licensedSeats = new Feature("licensed-seats", { - type: "number", - defaultValue: 0, +const chargebee = new Chargebee({ + site: process.env.CHARGEBEE_SITE!, + apiKey: process.env.CHARGEBEE_API_KEY!, }); -// Fetch the value for the current request's billing context. -const seats = await licensedSeats.get({ - mode: "customer", - customerId: ctx.user.chargebeeCustomerId, -}); // number +export const entitlements = new ChargebeeEntitlements({ + chargebeeClient: chargebee, +}); ``` -`get` accepts the same explicit `ChargebeeTarget` or context-shaped bag as the -underlying methods. `type` is one of `"boolean"`, `"string"`, `"number"`, or -`"object"`, and `defaultValue` must match it. - -A standalone `new Feature(...)` needs a client to evaluate against. Register one -once during start-up: +A standalone `new Feature(...)` needs a client to evaluate against. Register +one once during start-up: ```ts -import { setDefaultEntitlements } from "@chargebee/entitlements/server"; +import { setDefaultEntitlements } from "@chargebee/entitlements"; import { entitlements } from "@/lib/entitlements"; setDefaultEntitlements(entitlements); @@ -108,36 +109,33 @@ To avoid the global, create features from the client instead — the returned feature is bound to it: ```ts -const licensedSeats = entitlements.feature("licensed-seats", { - type: "number", - defaultValue: 0, -}); +const seats = entitlements.feature("licensed-seats", 0); ``` -When you need the reason or Chargebee metadata behind a value, call -`getDetails`, which returns the same `EntitlementResolution` the explicit -methods do: +For call sites that want the full resolution rather than a declared feature, +the client evaluates a feature ID directly: ```ts -const { value, reason, flagMetadata } = await licensedSeats.getDetails(target); +const resolution = await entitlements.getValue("licensed-seats", 0, { + customerId: session.user.chargebeeCustomerId, +}); ``` -Both `get` and `getDetails` accept `{ client, logger }` to override the bound or -default client for a single call. - ## Entitlement mapping -| Chargebee value | Resolved as | -| --- | --- | -| Switch (`true`, `false`, or `available`) | Boolean | -| Custom level | String | -| Quantity or range | Number | -| `unlimited` | `Number.POSITIVE_INFINITY` with `unlimited` metadata | -| Full sanitized entitlement | Object | +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`; invalid value -types return `TYPE_MISMATCH`. +`DISABLED` reason. Missing features return `FLAG_NOT_FOUND`; a value that +cannot be read as the declared type returns `TYPE_MISMATCH`. ## Snapshot resolution @@ -147,9 +145,10 @@ not make additional Chargebee calls. A snapshot is resolved in three steps: -1. `cache` — a shared, fast store such as Redis. It absorbs the many +1. `cache` — a shared, fast store such as Redis or in-memory. It absorbs the many entitlement checks a single authenticated request makes. -2. `store` — a durable store such as PostgreSQL. It is the source of truth. +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. @@ -170,9 +169,9 @@ const cache = createRedisEntitlementsCache(redis, { ttlMs: 60_000 }); export const entitlements = new ChargebeeEntitlements({ chargebeeClient: chargebee, cache, - store: postgresSnapshotStore, + durableStore: postgresSnapshotStore, snapshotTtlMs: 24 * 60 * 60_000, - cacheNamespace: "my-app:chargebee:entitlements:v1", + advanced: { cacheNamespace: "my-app:chargebee:entitlements:v1" }, }); ``` @@ -183,7 +182,10 @@ three-method `EntitlementsStorage` interface directly instead: ```ts import type { EntitlementsStorage } from "@chargebee/entitlements/cache"; -import { parseSerializedEntitlementsSnapshot, serializeEntitlementsSnapshot } from "@chargebee/entitlements"; +import { + parseSerializedEntitlementsSnapshot, + serializeEntitlementsSnapshot, +} from "@chargebee/entitlements/cache"; const cache: EntitlementsStorage = { get: async (key) => { @@ -214,23 +216,23 @@ 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 -`refreshBackoffMs`. +`advanced.refreshBackoffMs`. Refresh the snapshot after processing relevant Chargebee webhooks, and remove it when the target no longer exists: ```ts -await entitlements.refreshSnapshot({ mode: "subscription", subscriptionId }); -await entitlements.deleteSnapshot({ mode: "customer", customerId }); +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 -`evictCachedSnapshot` on its own when you want that fall-through without -fetching, and `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. +`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 @@ -245,8 +247,9 @@ application can render its free-tier experience and re-check once const entitlements = new ChargebeeEntitlements({ chargebeeClient: chargebee, cache, - store: postgresSnapshotStore, + durableStore: postgresSnapshotStore, refreshOnMiss: "background", + logger: console, onSnapshotRefreshed: ({ target, trigger }) => { if (trigger === "request") notifySubscriptionReady(target); }, @@ -287,33 +290,41 @@ export const GET = createEntitlementsRelayHandler({ const session = await getSession(request); if (!session?.user.chargebeeCustomerId) return null; - return { - targetingKey: session.user.id, - chargebeeCustomerId: session.user.chargebeeCustomerId, - }; + return { customerId: session.user.chargebeeCustomerId }; }, }); ``` `resolveContext` must derive billing identity from the authenticated server -session. The relay rejects Chargebee identity query parameters, emits -`private, no-store` responses, and never returns customer IDs, subscription -IDs, or credentials. `createEntitlementsRelayHandler` from +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`. -Use the browser client directly: +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 entitlements = new ChargebeeEntitlementsWebClient({ +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(); +``` -await entitlements.initialize(); -const enabled = entitlements.getBooleanValue("advanced-reports", false); +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 @@ -334,8 +345,8 @@ snapshots for multiple subscriptions. - Customer mode uses `consolidate_entitlements=true` by default. - Chargebee pagination is followed with a page size of 100. -- Without a `store`, an expired snapshot is refetched from Chargebee before the - evaluation resolves; stale grants are not served. +- 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. diff --git a/packages/entitlements/package.json b/packages/entitlements/package.json index e8fc3fe..4707ce1 100644 --- a/packages/entitlements/package.json +++ b/packages/entitlements/package.json @@ -89,8 +89,7 @@ "access": "public" }, "dependencies": { - "server-only": "^0.0.1", - "zod": "^4.4.3" + "server-only": "^0.0.1" }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", @@ -111,7 +110,7 @@ }, "peerDependenciesMeta": { "chargebee": { - "optional": true + "optional": false }, "ioredis": { "optional": true diff --git a/packages/entitlements/src/cache/index.ts b/packages/entitlements/src/cache/index.ts index cef1844..2e542d3 100644 --- a/packages/entitlements/src/cache/index.ts +++ b/packages/entitlements/src/cache/index.ts @@ -1,3 +1,8 @@ +export type { ChargebeeEntitlementsSnapshot } from "../shared"; +export { + parseSerializedEntitlementsSnapshot, + serializeEntitlementsSnapshot, +} from "../shared"; export { createEntitlementsCacheKey } from "./key"; export { createMemoryEntitlementsCache, diff --git a/packages/entitlements/src/cache/key.ts b/packages/entitlements/src/cache/key.ts index cd8e962..8e67d62 100644 --- a/packages/entitlements/src/cache/key.ts +++ b/packages/entitlements/src/cache/key.ts @@ -8,7 +8,7 @@ export function createEntitlementsCacheKey( } = {}, ): string { const namespace = options.namespace ?? "chargebee:entitlements:v1"; - if (target.mode === "subscription") { + if (target.subscriptionId !== undefined) { return `${namespace}:subscription:${encodeURIComponent(target.subscriptionId)}`; } diff --git a/packages/entitlements/src/index.ts b/packages/entitlements/src/index.ts index 2cae3b0..4093259 100644 --- a/packages/entitlements/src/index.ts +++ b/packages/entitlements/src/index.ts @@ -1,30 +1,12 @@ export type { ChargebeeEntitlement, - ChargebeeEntitlementsSnapshot, - ChargebeeEvaluationMode, ChargebeeTarget, EntitlementErrorCode, EntitlementResolution, - EntitlementsEvaluator, - EvaluationContextLike, - FeatureDefinition, - FeatureGetOptions, - FeatureTarget, - FeatureType, - FeatureTypeFor, Logger, - SnapshotSource, } from "./shared"; export { - CHARGEBEE_CONTEXT_KEYS, - createEntitlementsSnapshot, + type EntitlementsClient, Feature, - getDefaultEntitlements, - getTargetFromContext, - isSnapshotExpired, - parseEntitlementsSnapshot, - parseSerializedEntitlementsSnapshot, - serializeEntitlementsSnapshot, setDefaultEntitlements, - toResolutionDetails, } from "./shared"; diff --git a/packages/entitlements/src/nextjs.ts b/packages/entitlements/src/nextjs.ts index f332477..b06c250 100644 --- a/packages/entitlements/src/nextjs.ts +++ b/packages/entitlements/src/nextjs.ts @@ -4,8 +4,7 @@ import type { NextRequest } from "next/server"; import { type CreateEntitlementsRelayHandlerOptions, createEntitlementsRelayHandler as createRelayHandler, - type EntitlementsRelayHandler, -} from "./server"; +} from "./server/relay"; /** * Builds an App Router `GET` handler that resolves billing identity on the @@ -15,8 +14,8 @@ import { */ export function createEntitlementsRelayHandler( options: CreateEntitlementsRelayHandlerOptions, -): EntitlementsRelayHandler { +): (request: NextRequest) => Promise { return createRelayHandler(options); } -export type { CreateEntitlementsRelayHandlerOptions, EntitlementsRelayHandler }; +export type { CreateEntitlementsRelayHandlerOptions }; diff --git a/packages/entitlements/src/server/entitlements.ts b/packages/entitlements/src/server/entitlements.ts index 7ddf8a3..7f37ecb 100644 --- a/packages/entitlements/src/server/entitlements.ts +++ b/packages/entitlements/src/server/entitlements.ts @@ -1,21 +1,15 @@ import { createEntitlementsCacheKey, type EntitlementsStorage } from "../cache"; import { + assertTarget, type ChargebeeEntitlementsSnapshot, - type ChargebeeEvaluationMode, type ChargebeeTarget, createEntitlementsSnapshot, type EntitlementResolution, - type EvaluationContextLike, errorResolution, Feature, - type FeatureDefinition, - getTargetFromContext, isSnapshotExpired, type Logger, - resolveBooleanEntitlement, - resolveNumberEntitlement, - resolveObjectEntitlement, - resolveStringEntitlement, + resolveEntitlement, type SnapshotSource, } from "../shared"; import { @@ -23,14 +17,7 @@ import { ChargebeeEntitlementsLoader, } from "./loader"; -/** - * 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 the caller can fall back to its own defaults. - */ -export type RefreshOnMiss = "blocking" | "background"; - -export type SnapshotOperation = +type SnapshotOperation = | "cache-read" | "cache-write" | "cache-delete" @@ -53,24 +40,32 @@ export interface SnapshotRefreshedEvent { export interface ChargebeeEntitlementsOptions { chargebeeClient: ChargebeeEntitlementsClient; - defaultMode?: ChargebeeEvaluationMode; - consolidateCustomerEntitlements?: boolean; - resolveTarget?: (context: EvaluationContextLike) => ChargebeeTarget; - /** Fast shared cache (Redis or similar) read before the store. */ + /** 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. */ - store?: EntitlementsStorage; - cacheNamespace?: string; + durableStore?: EntitlementsStorage; + /** How long a snapshot is valid before it is considered expired. Defaults to 300 000 ms. */ snapshotTtlMs?: number; - refreshOnMiss?: RefreshOnMiss; - /** Minimum gap between background refresh attempts after a failure. */ - refreshBackoffMs?: 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; - pageSize?: number; - maxPages?: number; + /** 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 { @@ -92,18 +87,6 @@ export class SnapshotPendingError extends Error { } } -function isChargebeeTarget( - target: ChargebeeTarget | EvaluationContextLike, -): target is ChargebeeTarget { - const candidate = target as Partial; - return candidate.mode === "customer" - ? typeof (candidate as { customerId?: unknown }).customerId === "string" - : candidate.mode === "subscription" - ? typeof (candidate as { subscriptionId?: unknown }).subscriptionId === - "string" - : false; -} - /** * Framework-agnostic Chargebee entitlements client. Resolves a snapshot from * a shared cache, a durable store, or the Chargebee API, and evaluates @@ -111,17 +94,13 @@ function isChargebeeTarget( * as `@chargebee/openfeature`'s `ChargebeeEntitlementsProvider`. */ export class ChargebeeEntitlements { - private readonly defaultMode: ChargebeeEvaluationMode; private readonly consolidateCustomerEntitlements: boolean; - private readonly resolveTargetOption?: ( - context: EvaluationContextLike, - ) => ChargebeeTarget; private readonly cache?: EntitlementsStorage; private readonly cacheTtlMs?: number; - private readonly store?: EntitlementsStorage; + private readonly durableStore?: EntitlementsStorage; private readonly cacheNamespace?: string; private readonly snapshotTtlMs: number; - private readonly refreshOnMiss: RefreshOnMiss; + private readonly refreshOnMiss: "blocking" | "background"; private readonly refreshBackoffMs: number; private readonly onSnapshotRefreshed?: ( event: SnapshotRefreshedEvent, @@ -138,17 +117,15 @@ export class ChargebeeEntitlements { if (!options?.chargebeeClient) throw new Error("chargebeeClient is required"); - this.defaultMode = options.defaultMode ?? "customer"; this.consolidateCustomerEntitlements = - options.consolidateCustomerEntitlements ?? true; - this.resolveTargetOption = options.resolveTarget; + options.advanced?.consolidateCustomerEntitlements ?? true; this.cache = options.cache; this.cacheTtlMs = options.cacheTtlMs; - this.store = options.store; - this.cacheNamespace = options.cacheNamespace; + this.durableStore = options.durableStore; + this.cacheNamespace = options.advanced?.cacheNamespace; this.snapshotTtlMs = options.snapshotTtlMs ?? 300_000; this.refreshOnMiss = options.refreshOnMiss ?? "blocking"; - this.refreshBackoffMs = options.refreshBackoffMs ?? 10_000; + this.refreshBackoffMs = options.advanced?.refreshBackoffMs ?? 10_000; this.onSnapshotRefreshed = options.onSnapshotRefreshed; this.onError = options.onError; for (const [name, value] of [ @@ -162,8 +139,9 @@ export class ChargebeeEntitlements { this.loader = new ChargebeeEntitlementsLoader({ chargebeeClient: options.chargebeeClient, consolidateCustomerEntitlements: this.consolidateCustomerEntitlements, - pageSize: options.pageSize ?? 100, - maxPages: options.maxPages ?? 50, + pageSize: options.advanced?.pageSize ?? 100, + maxPages: options.advanced?.maxPages ?? 50, + logger: options.logger, }); } @@ -173,64 +151,19 @@ export class ChargebeeEntitlements { this.failedAt.clear(); } - getBooleanValue( - flagKey: string, - defaultValue: boolean, - target: ChargebeeTarget | EvaluationContextLike, - logger?: Logger, - ): Promise> { - return this.evaluate(defaultValue, target, logger, (result) => - resolveBooleanEntitlement( - result.snapshot, - flagKey, - defaultValue, - result.source, - ), - ); - } - - getStringValue( - flagKey: string, - defaultValue: string, - target: ChargebeeTarget | EvaluationContextLike, - logger?: Logger, - ): Promise> { - return this.evaluate(defaultValue, target, logger, (result) => - resolveStringEntitlement( - result.snapshot, - flagKey, - defaultValue, - result.source, - ), - ); - } - - getNumberValue( - flagKey: string, - defaultValue: number, - target: ChargebeeTarget | EvaluationContextLike, - logger?: Logger, - ): Promise> { - return this.evaluate(defaultValue, target, logger, (result) => - resolveNumberEntitlement( - result.snapshot, - flagKey, - defaultValue, - result.source, - ), - ); - } - - getObjectValue( - flagKey: string, + /** + * 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 | EvaluationContextLike, - logger?: Logger, + target: ChargebeeTarget, ): Promise> { - return this.evaluate(defaultValue, target, logger, (result) => - resolveObjectEntitlement( + return this.evaluate(defaultValue, target, (result) => + resolveEntitlement( result.snapshot, - flagKey, + featureId, defaultValue, result.source, ), @@ -242,16 +175,13 @@ export class ChargebeeEntitlements { * single concise call: * * ```ts - * const seats = entitlements.feature("licensed-seats", { - * type: "number", - * defaultValue: 0, - * }); + * const seats = entitlements.feature("licensed-seats", 0); * - * const value = await seats.get({ mode: "customer", customerId }); + * const count = await seats.get({ customerId }); * ``` */ - feature(featureId: string, definition: FeatureDefinition): Feature { - return new Feature(featureId, { ...definition, client: this }); + feature(featureId: string, defaultValue: T): Feature { + return new Feature(featureId, defaultValue, this); } /** @@ -260,37 +190,36 @@ export class ChargebeeEntitlements { * `refreshOnMiss` is `background`. */ async getSnapshot( - target: ChargebeeTarget | EvaluationContextLike, - logger?: Logger, + 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, logger); + this.refreshIfExpired(cached, resolved, key); return { snapshot: cached, source: "cache" }; } - const stored = await this.read(this.store, key, resolved, "store-read"); + 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, logger); + this.refreshIfExpired(stored, resolved, key); return { snapshot: stored, source: "store" }; } if (this.refreshOnMiss === "background") { - this.scheduleRefresh(resolved, key, logger); + this.scheduleRefresh(resolved, key); throw new SnapshotPendingError(resolved); } return { - snapshot: await this.fetchAndPersistSnapshot( - resolved, - key, - "request", - logger, - ), + snapshot: await this.fetchAndPersistSnapshot(resolved, key, "request"), source: "api", }; } @@ -305,23 +234,18 @@ export class ChargebeeEntitlements { */ async refreshSnapshot( target: ChargebeeTarget, - logger?: Logger, ): Promise { - const key = this.cacheKey(target); + 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, target); + await this.evictCache(key, resolved); this.cancelInFlight(key); return { - snapshot: await this.fetchAndPersistSnapshot( - target, - key, - "explicit", - logger, - ), + snapshot: await this.fetchAndPersistSnapshot(resolved, key, "explicit"), source: "api", }; } @@ -331,35 +255,26 @@ export class ChargebeeEntitlements { target: ChargebeeTarget, snapshot: ChargebeeEntitlementsSnapshot, ): Promise { - if (snapshot.targetMode !== target.mode) { - throw new Error( - `Snapshot target mode ${snapshot.targetMode} does not match ${target.mode}`, - ); - } - const key = this.cacheKey(target); + const resolved = this.resolveTarget(target); + const key = this.cacheKey(resolved); this.cancelInFlight(key); - await this.persist(key, snapshot, target); + await this.persist(key, snapshot, resolved); } - /** Removes the snapshot from both the cache and the store. */ + /** Removes the snapshot from both the cache and the durable store. */ async deleteSnapshot(target: ChargebeeTarget): Promise { - const key = this.cacheKey(target); + const resolved = this.resolveTarget(target); + const key = this.cacheKey(resolved); this.cancelInFlight(key); - await this.evictCache(key, target); - await this.store?.delete(key); - } - - /** Drops the cached copy, leaving the store as the next read's source. */ - async evictCachedSnapshot(target: ChargebeeTarget): Promise { - await this.evictCache(this.cacheKey(target), target); + await this.evictCache(key, resolved); + await this.durableStore?.delete(key); } async getRelaySnapshot( - target: ChargebeeTarget | EvaluationContextLike, + target: ChargebeeTarget, ttlMs = 60_000, - logger?: Logger, ): Promise { - const { snapshot } = await this.getSnapshot(target, logger); + const { snapshot } = await this.getSnapshot(target); return { ...snapshot, expiresAt: new Date( @@ -370,12 +285,11 @@ export class ChargebeeEntitlements { private async evaluate( defaultValue: T, - target: ChargebeeTarget | EvaluationContextLike, - logger: Logger | undefined, + target: ChargebeeTarget, resolve: (result: EntitlementsSnapshotResult) => EntitlementResolution, ): Promise> { try { - return resolve(await this.getSnapshot(target, logger)); + return resolve(await this.getSnapshot(target)); } catch (error) { if (error instanceof SnapshotPendingError) { return { @@ -396,20 +310,28 @@ export class ChargebeeEntitlements { } } - private async read( + 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 { - if (!storage) return undefined; - try { - return await storage.get(key); - } catch (error) { - // A storage outage degrades to the next link in the chain. - this.onError?.(error, { operation, target }); - return undefined; - } + return this.safeStorageOp(storage, operation, target, (store) => + store.get(key), + ); } private async write( @@ -419,28 +341,22 @@ export class ChargebeeEntitlements { target: ChargebeeTarget, operation: Extract, ): Promise { - if (!storage) return; - try { - await storage.set( + await this.safeStorageOp(storage, operation, target, (store) => + store.set( key, snapshot, operation === "cache-write" ? this.cacheTtlMs : this.snapshotTtlMs, - ); - } catch (error) { - this.onError?.(error, { operation, target }); - } + ), + ); } private async evictCache( key: string, target: ChargebeeTarget, ): Promise { - if (!this.cache) return; - try { - await this.cache.delete(key); - } catch (error) { - this.onError?.(error, { operation: "cache-delete", target }); - } + await this.safeStorageOp(this.cache, "cache-delete", target, (store) => + store.delete(key), + ); } /** @@ -451,32 +367,24 @@ export class ChargebeeEntitlements { snapshot: ChargebeeEntitlementsSnapshot, target: ChargebeeTarget, key: string, - logger?: Logger, ): void { - if (isSnapshotExpired(snapshot)) this.scheduleRefresh(target, key, logger); + if (isSnapshotExpired(snapshot)) this.scheduleRefresh(target, key); } - private scheduleRefresh( - target: ChargebeeTarget, - key: string, - logger?: Logger, - ): void { + 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", logger).catch( - () => { - // Reported through onError inside fetchAndPersistSnapshot. - }, - ); + void this.fetchAndPersistSnapshot(target, key, "request").catch(() => { + // Reported through onError inside fetchAndPersistSnapshot. + }); } private fetchAndPersistSnapshot( target: ChargebeeTarget, key: string, trigger: SnapshotRefreshedEvent["trigger"], - logger?: Logger, ): Promise { const current = this.inFlight.get(key); if (current) return current.promise; @@ -486,8 +394,7 @@ export class ChargebeeEntitlements { promise: Promise.resolve().then(async () => { try { const snapshot = createEntitlementsSnapshot( - target.mode, - await this.loader.load(target, logger), + await this.loader.load(target), this.snapshotTtlMs, ); if (!request.cancelled) { @@ -509,13 +416,14 @@ export class ChargebeeEntitlements { return request.promise; } - /** The store is the source of truth, so its write failures propagate. */ + /** 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.store) await this.store.set(key, snapshot, this.snapshotTtlMs); + if (this.durableStore) + await this.durableStore.set(key, snapshot, this.snapshotTtlMs); await this.write(this.cache, key, snapshot, target, "cache-write"); } @@ -525,17 +433,12 @@ export class ChargebeeEntitlements { this.inFlight.delete(key); } - private resolveTarget( - target: ChargebeeTarget | EvaluationContextLike, - ): ChargebeeTarget { - if (isChargebeeTarget(target)) return target; + private resolveTarget(target: ChargebeeTarget): ChargebeeTarget { try { - return this.resolveTargetOption - ? this.resolveTargetOption(target) - : getTargetFromContext(target, this.defaultMode); + return assertTarget(target); } catch (error) { throw new InvalidEntitlementContextError( - error instanceof Error ? error.message : "Invalid evaluation context", + error instanceof Error ? error.message : "Invalid Chargebee target", ); } } diff --git a/packages/entitlements/src/server/index.ts b/packages/entitlements/src/server/index.ts index 33c0040..1df988d 100644 --- a/packages/entitlements/src/server/index.ts +++ b/packages/entitlements/src/server/index.ts @@ -1,28 +1,12 @@ -export { - type EntitlementsEvaluator, - Feature, - type FeatureDefinition, - type FeatureGetOptions, - type FeatureTarget, - type FeatureType, - type FeatureTypeFor, - getDefaultEntitlements, - setDefaultEntitlements, -} from "../shared"; +export { createEntitlementsSnapshot } from "../shared"; export { ChargebeeEntitlements, - type ChargebeeEntitlementsClient, type ChargebeeEntitlementsOptions, - type EntitlementsSnapshotResult, - type RefreshOnMiss, type SnapshotErrorInfo, - type SnapshotOperation, SnapshotPendingError, type SnapshotRefreshedEvent, } from "./entitlements"; export { type CreateEntitlementsRelayHandlerOptions, createEntitlementsRelayHandler, - type EntitlementsRelayHandler, - type EntitlementsRelaySource, } from "./relay"; diff --git a/packages/entitlements/src/server/loader.ts b/packages/entitlements/src/server/loader.ts index b118984..197506e 100644 --- a/packages/entitlements/src/server/loader.ts +++ b/packages/entitlements/src/server/loader.ts @@ -17,14 +17,34 @@ interface EntitlementsLoaderOptions { 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 ( @@ -39,16 +59,29 @@ export class ChargebeeEntitlementsLoader { } } - load( - target: ChargebeeTarget, - logger?: Logger, - ): Promise { - return target.mode === "customer" - ? this.loadCustomer(target.customerId, logger) - : this.loadSubscription(target.subscriptionId, logger); - } + load(target: ChargebeeTarget): Promise { + const normalize = (item: { + customer_entitlement?: CustomerEntitlement; + subscription_entitlement?: SubscriptionEntitlement; + }) => + normalizeEntitlement( + item.customer_entitlement ?? item.subscription_entitlement ?? {}, + ); - private loadCustomer(customerId: string, logger?: Logger) { + 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( @@ -60,32 +93,15 @@ export class ChargebeeEntitlementsLoader { this.options.consolidateCustomerEntitlements, }, ), - (item: { customer_entitlement: CustomerEntitlement }) => - this.normalizeCustomer(item.customer_entitlement), + normalize, "customer", - logger, - ); - } - - private loadSubscription(subscriptionId: string, logger?: Logger) { - return this.collect( - (offset) => - this.options.chargebeeClient.subscriptionEntitlement.subscriptionEntitlementsForSubscription( - subscriptionId, - { limit: this.options.pageSize, offset }, - ), - (item: { subscription_entitlement: SubscriptionEntitlement }) => - this.normalizeSubscription(item.subscription_entitlement), - "subscription", - logger, ); } private async collect( loadPage: (offset?: string) => Promise>, normalize: (item: T) => ChargebeeEntitlement | undefined, - scope: ChargebeeTarget["mode"], - logger?: Logger, + scope: "customer" | "subscription", ): Promise { const entitlements: ChargebeeEntitlement[] = []; let offset: string | undefined; @@ -96,7 +112,9 @@ export class ChargebeeEntitlementsLoader { const entitlement = normalize(item); if (entitlement) entitlements.push(entitlement); else - logger?.warn(`Chargebee ${scope} entitlement omitted a feature_id`); + this.options.logger?.warn( + `Chargebee ${scope} entitlement omitted a feature_id`, + ); } offset = response.next_offset; @@ -107,33 +125,4 @@ export class ChargebeeEntitlementsLoader { `Chargebee ${scope} entitlement pagination exceeded ${this.options.maxPages} pages`, ); } - - private normalizeCustomer( - entitlement: CustomerEntitlement, - ): ChargebeeEntitlement | undefined { - if (!entitlement.feature_id) return undefined; - return withoutUndefined({ - featureId: entitlement.feature_id, - isEnabled: entitlement.is_enabled, - value: entitlement.value, - name: entitlement.name, - }); - } - - private normalizeSubscription( - entitlement: SubscriptionEntitlement, - ): ChargebeeEntitlement | undefined { - if (!entitlement.feature_id) return undefined; - return withoutUndefined({ - featureId: entitlement.feature_id, - isEnabled: entitlement.is_enabled, - 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, - }); - } } diff --git a/packages/entitlements/src/server/relay.ts b/packages/entitlements/src/server/relay.ts index f21f906..d38ccba 100644 --- a/packages/entitlements/src/server/relay.ts +++ b/packages/entitlements/src/server/relay.ts @@ -1,9 +1,4 @@ -import { - CHARGEBEE_CONTEXT_KEYS, - type ChargebeeEntitlementsSnapshot, - type ChargebeeTarget, - type EvaluationContextLike, -} from "../shared"; +import type { ChargebeeEntitlementsSnapshot, ChargebeeTarget } from "../shared"; export type EntitlementsRelayHandler = ( request: TRequest, @@ -12,7 +7,7 @@ export type EntitlementsRelayHandler = ( /** The subset of `ChargebeeEntitlements` (or an adapter over it) the relay needs. */ export interface EntitlementsRelaySource { getRelaySnapshot( - target: ChargebeeTarget | EvaluationContextLike, + target: ChargebeeTarget, ttlMs?: number, ): Promise; } @@ -21,17 +16,24 @@ 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 - | EvaluationContextLike - | null - | Promise; - snapshotTtlMs?: number; + ) => 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", @@ -58,23 +60,18 @@ export function createEntitlementsRelayHandler< } const search = new URL(request.url).searchParams; - if ( - Object.values(CHARGEBEE_CONTEXT_KEYS).some((key) => search.has(key)) - ) { + if (IDENTITY_PARAMS.some((key) => search.has(key))) { return json( { error: "Billing identity must be resolved by the server" }, 400, ); } - const context = await options.resolveContext(request); - if (!context) return json({ error: "Unauthorized" }, 401); + const target = await options.resolveContext(request); + if (!target) return json({ error: "Unauthorized" }, 401); return json( - await options.entitlements.getRelaySnapshot( - context, - options.snapshotTtlMs, - ), + await options.entitlements.getRelaySnapshot(target, options.relayTtlMs), 200, ); } catch (error) { diff --git a/packages/entitlements/src/shared/context.ts b/packages/entitlements/src/shared/context.ts deleted file mode 100644 index ff0616b..0000000 --- a/packages/entitlements/src/shared/context.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - CHARGEBEE_CONTEXT_KEYS, - type ChargebeeEvaluationMode, - type ChargebeeTarget, - type EvaluationContextLike, -} from "./types"; - -export function getTargetFromContext( - context: EvaluationContextLike, - defaultMode: ChargebeeEvaluationMode = "customer", -): ChargebeeTarget { - const explicitMode = context[CHARGEBEE_CONTEXT_KEYS.evaluationMode]; - const mode = - explicitMode === "customer" || explicitMode === "subscription" - ? explicitMode - : defaultMode; - const key = - mode === "customer" - ? CHARGEBEE_CONTEXT_KEYS.customerId - : CHARGEBEE_CONTEXT_KEYS.subscriptionId; - const id = context[key]; - - if (typeof id !== "string" || id.length === 0) { - throw new Error(`Evaluation context requires a non-empty ${key}`); - } - - return mode === "customer" - ? { mode, customerId: id } - : { mode, subscriptionId: id }; -} diff --git a/packages/entitlements/src/shared/evaluation.ts b/packages/entitlements/src/shared/evaluation.ts index ec3f35e..8fe9b81 100644 --- a/packages/entitlements/src/shared/evaluation.ts +++ b/packages/entitlements/src/shared/evaluation.ts @@ -11,6 +11,25 @@ type EnabledEntitlement = { 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, @@ -45,34 +64,18 @@ export function errorResolution( return { value, reason: "ERROR", errorCode, errorMessage }; } -/** - * 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, - }; -} - function getEnabled( snapshot: ChargebeeEntitlementsSnapshot, - flagKey: string, + featureId: string, defaultValue: T, source: SnapshotSource, ): EnabledEntitlement | EntitlementResolution { - const entitlement = snapshot.entitlements[flagKey]; + const entitlement = snapshot.entitlements[featureId]; if (!entitlement) { return errorResolution( defaultValue, "FLAG_NOT_FOUND", - `Chargebee feature ${flagKey} was not found`, + `Chargebee feature ${featureId} was not found`, ); } @@ -95,120 +98,115 @@ function getEnabled( const reasonFor = (source: SnapshotSource) => source === "api" ? "TARGETING_MATCH" : "CACHED"; -export function resolveBooleanEntitlement( - snapshot: ChargebeeEntitlementsSnapshot, - flagKey: string, - defaultValue: boolean, - source: SnapshotSource, -): EntitlementResolution { - const found = getEnabled(snapshot, flagKey, defaultValue, source); - if (!("entitlement" in found)) return found; - - const normalized = found.entitlement.value?.trim().toLowerCase(); - if ( - !normalized && - (found.entitlement.featureType === undefined || - found.entitlement.featureType === "switch") - ) { - return { - value: true, - variant: "enabled", - reason: reasonFor(source), - flagMetadata: found.metadata, - }; - } - if (!["true", "false", "available"].includes(normalized ?? "")) { - return errorResolution( - defaultValue, - "TYPE_MISMATCH", - `Chargebee feature ${flagKey} is not a boolean entitlement`, - ); - } - - const value = normalized === "true" || normalized === "available"; - return { - value, - variant: value ? "enabled" : "disabled", - reason: reasonFor(source), - flagMetadata: found.metadata, - }; +interface ParsedEntitlementValue { + value: unknown; + variant?: string; + extraMetadata?: Record; + error?: { code: EntitlementErrorCode; message: string }; } -export function resolveStringEntitlement( - snapshot: ChargebeeEntitlementsSnapshot, - flagKey: string, - defaultValue: string, - source: SnapshotSource, -): EntitlementResolution { - const found = getEnabled(snapshot, flagKey, defaultValue, source); - if (!("entitlement" in found)) return found; - - const value = found.entitlement.value; - if (value === undefined) { - return errorResolution( - defaultValue, - "PARSE_ERROR", - `Chargebee feature ${flagKey} has no value`, - ); +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", + }; } - - return { - value, - variant: value, - reason: reasonFor(source), - flagMetadata: found.metadata, - }; } -export function resolveNumberEntitlement( +/** + * 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, - flagKey: string, - defaultValue: number, + featureId: string, + defaultValue: T, source: SnapshotSource, -): EntitlementResolution { - const found = getEnabled(snapshot, flagKey, defaultValue, source); +): EntitlementResolution { + const found = getEnabled(snapshot, featureId, defaultValue, source); if (!("entitlement" in found)) return found; - const rawValue = found.entitlement.value?.trim(); - if (rawValue?.toLowerCase() === "unlimited") { - return { - value: Number.POSITIVE_INFINITY, - variant: "unlimited", - reason: reasonFor(source), - flagMetadata: { ...found.metadata, unlimited: true }, - }; - } - - const value = rawValue ? Number(rawValue) : Number.NaN; - if (!Number.isFinite(value)) { + const parsed = parseEntitlementValue( + found.entitlement, + kindOf(defaultValue), + featureId, + ); + if (parsed.error) { return errorResolution( defaultValue, - "TYPE_MISMATCH", - `Chargebee feature ${flagKey} is not a numeric entitlement`, + parsed.error.code, + parsed.error.message, ); } return { - value, - variant: rawValue, - reason: reasonFor(source), - flagMetadata: found.metadata, - }; -} - -export function resolveObjectEntitlement( - snapshot: ChargebeeEntitlementsSnapshot, - flagKey: string, - defaultValue: T, - source: SnapshotSource, -): EntitlementResolution { - const found = getEnabled(snapshot, flagKey, defaultValue, source); - if (!("entitlement" in found)) return found; - - return { - value: found.entitlement as unknown as T, - variant: found.entitlement.value ?? "enabled", + value: parsed.value as T, + variant: parsed.variant, reason: reasonFor(source), - flagMetadata: found.metadata, + 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 index f47315a..28f18b2 100644 --- a/packages/entitlements/src/shared/feature.ts +++ b/packages/entitlements/src/shared/feature.ts @@ -1,85 +1,19 @@ -import type { - ChargebeeTarget, - EntitlementResolution, - EvaluationContextLike, - Logger, -} from "./types"; - -/** A Chargebee evaluation target, or a context bag it can be derived from. */ -export type FeatureTarget = ChargebeeTarget | EvaluationContextLike; - -/** - * The declared shape of a {@link Feature}'s value. It selects the evaluation - * method used and, through {@link FeatureTypeFor}, the value type `get` - * resolves to. - */ -export type FeatureType = "boolean" | "string" | "number" | "object"; - -/** Maps a resolved value type back to the {@link FeatureType} that declares it. */ -export type FeatureTypeFor = [T] extends [boolean] - ? "boolean" - : [T] extends [string] - ? "string" - : [T] extends [number] - ? "number" - : "object"; +import type { ChargebeeTarget, EntitlementResolution } from "./types"; /** - * The subset of a Chargebee entitlements client a {@link Feature} evaluates - * against. The server `ChargebeeEntitlements` client satisfies it directly, so - * a feature can resolve values without holding a reference to the concrete - * class. + * The one method a {@link Feature} needs from a client. Both + * `ChargebeeEntitlements` (server) and `ChargebeeEntitlementsWebClient` (web) + * satisfy it directly. */ -export interface EntitlementsEvaluator { - getBooleanValue( - flagKey: string, - defaultValue: boolean, - target: FeatureTarget, - logger?: Logger, - ): Promise>; - getStringValue( - flagKey: string, - defaultValue: string, - target: FeatureTarget, - logger?: Logger, - ): Promise>; - getNumberValue( - flagKey: string, - defaultValue: number, - target: FeatureTarget, - logger?: Logger, - ): Promise>; - getObjectValue( - flagKey: string, +export interface EntitlementsClient { + getValue( + featureId: string, defaultValue: T, - target: FeatureTarget, - logger?: Logger, - ): Promise>; -} - -/** - * Declares a single feature. `type` and `defaultValue` must agree: declaring - * `type: "number"` requires a numeric `defaultValue`, which in turn types the - * value `get` resolves to. - */ -export interface FeatureDefinition { - type: FeatureTypeFor; - defaultValue: T; - /** - * Evaluates this feature instead of the client registered with - * {@link setDefaultEntitlements}. Prefer `entitlements.feature(...)` when a - * client is already in scope. - */ - client?: EntitlementsEvaluator; -} - -export interface FeatureGetOptions { - /** Overrides the feature's bound client and the default client for one call. */ - client?: EntitlementsEvaluator; - logger?: Logger; + target?: ChargebeeTarget, + ): Promise> | EntitlementResolution; } -let defaultEvaluator: EntitlementsEvaluator | undefined; +let defaultClient: EntitlementsClient | undefined; /** * Registers the client that standalone {@link Feature} instances evaluate @@ -87,112 +21,61 @@ let defaultEvaluator: EntitlementsEvaluator | undefined; * `feature.get(...)`. Pass `undefined` to clear it (useful in tests). */ export function setDefaultEntitlements( - client: EntitlementsEvaluator | undefined, + client: EntitlementsClient | undefined, ): void { - defaultEvaluator = client; -} - -/** Returns the client registered with {@link setDefaultEntitlements}, if any. */ -export function getDefaultEntitlements(): EntitlementsEvaluator | undefined { - return defaultEvaluator; + defaultClient = client; } /** - * A declared feature whose value is fetched with a single concise `get` call. + * A declared feature whose value is fetched with a single `get` call, typed as + * whatever the feature holds: * * ```ts - * const licensedSeats = new Feature("licensed-seats", { - * type: "number", - * defaultValue: 0, - * }); + * const seats = new Feature("licensed-seats", 0); * - * const seats = await licensedSeats.get({ - * mode: "customer", - * customerId: ctx.user.chargebeeCustomerId, - * }); + * const count = await seats.get({ customerId: user.chargebeeCustomerId }); * ``` * - * A standalone `new Feature(...)` resolves against the client registered with - * {@link setDefaultEntitlements}. Bind a specific client with - * `entitlements.feature(...)`, the `client` definition option, or the `client` - * option on `get`. + * 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 { - readonly featureId: string; - readonly type: FeatureType; - readonly defaultValue: T; - private readonly boundClient?: EntitlementsEvaluator; - - constructor(featureId: string, definition: FeatureDefinition) { + constructor( + readonly featureId: string, + readonly defaultValue: T, + private readonly client?: EntitlementsClient, + ) { if (!featureId) throw new Error("featureId is required"); - this.featureId = featureId; - this.type = definition.type; - this.defaultValue = definition.defaultValue; - this.boundClient = definition.client; } /** - * Resolves the feature's value for `target`, falling back to `defaultValue` - * when the feature is missing, disabled, or the snapshot is unavailable. Use - * {@link getDetails} when the reason or Chargebee metadata is needed. + * 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: FeatureTarget, options?: FeatureGetOptions): Promise { - return (await this.getDetails(target, options)).value; + async get(target?: ChargebeeTarget): Promise { + return (await this.getDetails(target)).value; } /** Like {@link get}, but returns the full resolution, not just the value. */ - getDetails( - target: FeatureTarget, - options?: FeatureGetOptions, + async getDetails( + target?: ChargebeeTarget, ): Promise> { - const client = options?.client ?? this.boundClient ?? defaultEvaluator; + 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), create the feature " + - "with entitlements.feature(...), or pass { client } to get().", + "Register one with setDefaultEntitlements(client), or create the " + + "feature with entitlements.feature(...).", ); } - const { logger } = options ?? {}; - switch (this.type) { - case "boolean": - return client.getBooleanValue( - this.featureId, - this.defaultValue as boolean, - target, - logger, - ) as Promise>; - case "string": - return client.getStringValue( - this.featureId, - this.defaultValue as string, - target, - logger, - ) as Promise>; - case "number": - return client.getNumberValue( - this.featureId, - this.defaultValue as number, - target, - logger, - ) as Promise>; - default: - return client.getObjectValue( - this.featureId, - this.defaultValue, - target, - logger, - ); - } - } - - /** Returns a copy of this feature bound to `client`. */ - withClient(client: EntitlementsEvaluator): Feature { - return new Feature(this.featureId, { - type: this.type as FeatureTypeFor, - defaultValue: this.defaultValue, - client, - }); + return client.getValue(this.featureId, this.defaultValue, target); } } diff --git a/packages/entitlements/src/shared/index.ts b/packages/entitlements/src/shared/index.ts index 1a33bac..e75eef6 100644 --- a/packages/entitlements/src/shared/index.ts +++ b/packages/entitlements/src/shared/index.ts @@ -1,21 +1,7 @@ -export { getTargetFromContext } from "./context"; +export { errorResolution, resolveEntitlement } from "./evaluation"; export { - errorResolution, - resolveBooleanEntitlement, - resolveNumberEntitlement, - resolveObjectEntitlement, - resolveStringEntitlement, - toResolutionDetails, -} from "./evaluation"; -export { - type EntitlementsEvaluator, + type EntitlementsClient, Feature, - type FeatureDefinition, - type FeatureGetOptions, - type FeatureTarget, - type FeatureType, - type FeatureTypeFor, - getDefaultEntitlements, setDefaultEntitlements, } from "./feature"; export { @@ -25,15 +11,13 @@ export { parseSerializedEntitlementsSnapshot, serializeEntitlementsSnapshot, } from "./snapshot"; -export { - CHARGEBEE_CONTEXT_KEYS, - type ChargebeeEntitlement, - type ChargebeeEntitlementsSnapshot, - type ChargebeeEvaluationMode, - type ChargebeeTarget, - type EntitlementErrorCode, - type EntitlementResolution, - type EvaluationContextLike, - type Logger, - type SnapshotSource, +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 index dee3c10..a6d1742 100644 --- a/packages/entitlements/src/shared/snapshot.ts +++ b/packages/entitlements/src/shared/snapshot.ts @@ -1,34 +1,62 @@ -import { z } from "zod"; import type { ChargebeeEntitlement, ChargebeeEntitlementsSnapshot, - ChargebeeEvaluationMode, } from "./types"; -const entitlementSchema = z.object({ - featureId: z.string().min(1), - value: z.string().optional(), - name: z.string().optional(), - featureName: z.string().optional(), - featureUnit: z.string().optional(), - featureType: z.string().optional(), - isEnabled: z.boolean(), - isOverridden: z.boolean().optional(), - expiresAt: z.number().int().optional(), -}); +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)); +} -const snapshotSchema = z.object({ - schemaVersion: z.literal(1), - generatedAt: z.iso.datetime(), - expiresAt: z.iso.datetime(), - targetMode: z.enum(["customer", "subscription"]), - entitlements: z.record(z.string(), entitlementSchema), -}); +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 { - return snapshotSchema.parse(input); + 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( @@ -44,7 +72,6 @@ export function parseSerializedEntitlementsSnapshot( } export function createEntitlementsSnapshot( - targetMode: ChargebeeEvaluationMode, entitlements: Iterable, ttlMs: number, now = Date.now(), @@ -53,7 +80,6 @@ export function createEntitlementsSnapshot( schemaVersion: 1, generatedAt: new Date(now).toISOString(), expiresAt: new Date(now + ttlMs).toISOString(), - targetMode, entitlements: Object.fromEntries( [...entitlements].map((entitlement) => [ entitlement.featureId, 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 index 7de9ca6..b7e850a 100644 --- a/packages/entitlements/src/shared/types.ts +++ b/packages/entitlements/src/shared/types.ts @@ -1,18 +1,12 @@ -export const CHARGEBEE_CONTEXT_KEYS = { - customerId: "chargebeeCustomerId", - subscriptionId: "chargebeeSubscriptionId", - evaluationMode: "chargebeeEvaluationMode", -} as const; - -export type ChargebeeEvaluationMode = "customer" | "subscription"; - +/** + * 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 = - | { mode: "customer"; customerId: string } - | { mode: "subscription"; subscriptionId: string }; - -export type EvaluationContextLike = Record & { - targetingKey?: string; -}; + | { customerId: string; subscriptionId?: never } + | { subscriptionId: string; customerId?: never }; /** * A minimal, framework-agnostic logger. Structurally compatible with @@ -41,7 +35,6 @@ export interface ChargebeeEntitlementsSnapshot { schemaVersion: 1; generatedAt: string; expiresAt: string; - targetMode: ChargebeeEvaluationMode; entitlements: Record; } diff --git a/packages/entitlements/src/web/client.ts b/packages/entitlements/src/web/client.ts index b9c41eb..09029ad 100644 --- a/packages/entitlements/src/web/client.ts +++ b/packages/entitlements/src/web/client.ts @@ -2,12 +2,10 @@ import { type ChargebeeEntitlementsSnapshot, type EntitlementResolution, errorResolution, + Feature, isSnapshotExpired, parseEntitlementsSnapshot, - resolveBooleanEntitlement, - resolveNumberEntitlement, - resolveObjectEntitlement, - resolveStringEntitlement, + resolveEntitlement, } from "../shared"; export interface ChargebeeEntitlementsWebClientOptions { @@ -96,50 +94,24 @@ export class ChargebeeEntitlementsWebClient { return this.loadSnapshot(true); } - getBooleanValue( - flagKey: string, - defaultValue: boolean, - ): EntitlementResolution { - return this.evaluate(defaultValue, (snapshot) => - resolveBooleanEntitlement(snapshot, flagKey, defaultValue, "relay"), - ); - } - - getStringValue( - flagKey: string, - defaultValue: string, - ): EntitlementResolution { - return this.evaluate(defaultValue, (snapshot) => - resolveStringEntitlement(snapshot, flagKey, defaultValue, "relay"), - ); - } - - getNumberValue( - flagKey: string, - defaultValue: number, - ): EntitlementResolution { - return this.evaluate(defaultValue, (snapshot) => - resolveNumberEntitlement(snapshot, flagKey, defaultValue, "relay"), - ); - } - - getObjectValue( - flagKey: string, - defaultValue: T, - ): EntitlementResolution { - return this.evaluate(defaultValue, (snapshot) => - resolveObjectEntitlement(snapshot, flagKey, defaultValue, "relay"), - ); + /** + * 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; } - private evaluate( - defaultValue: T, - resolve: ( - snapshot: ChargebeeEntitlementsSnapshot, - ) => EntitlementResolution, - ): EntitlementResolution { - const snapshot = this.getUsableSnapshot(defaultValue); - return "entitlements" in snapshot ? resolve(snapshot) : 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( diff --git a/packages/entitlements/test/cache.test.ts b/packages/entitlements/test/cache.test.ts index 482d6fe..4a91c2f 100644 --- a/packages/entitlements/test/cache.test.ts +++ b/packages/entitlements/test/cache.test.ts @@ -20,7 +20,6 @@ function makeRedisClient(values = new Map()) { function makeSnapshot(ttlMs = 60_000) { return createEntitlementsSnapshot( - "customer", [{ featureId: "sso", value: "true", isEnabled: true }], ttlMs, ); diff --git a/packages/entitlements/test/feature.test.ts b/packages/entitlements/test/feature.test.ts index e92b2f3..73a6c43 100644 --- a/packages/entitlements/test/feature.test.ts +++ b/packages/entitlements/test/feature.test.ts @@ -1,14 +1,12 @@ import type { CustomerEntitlement } from "chargebee"; -import { - ChargebeeEntitlements, - type ChargebeeEntitlementsClient, -} from "../src/server"; -import type { EntitlementResolution, FeatureTarget } from "../src/shared"; -import { - type EntitlementsEvaluator, - Feature, - setDefaultEntitlements, +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 () => ({ @@ -48,7 +46,7 @@ function makeEntitlements() { return new ChargebeeEntitlements({ chargebeeClient: client }); } -const target: FeatureTarget = { mode: "customer", customerId: "customer-1" }; +const target: ChargebeeTarget = { customerId: "customer-1" }; afterEach(() => { setDefaultEntitlements(undefined); @@ -58,10 +56,7 @@ describe("Feature", () => { it("resolves the unwrapped value against the default client", async () => { setDefaultEntitlements(makeEntitlements()); - const licensedSeats = new Feature("licensed-seats", { - type: "number", - defaultValue: 0, - }); + const licensedSeats = new Feature("licensed-seats", 0); const seats = await licensedSeats.get(target); @@ -69,21 +64,12 @@ describe("Feature", () => { expect(seats).toBe(25); }); - it("infers the value type from the declared feature type", async () => { + it("takes the value type from the declared default value", async () => { const entitlements = makeEntitlements(); - const advancedReports = entitlements.feature("advanced-reports", { - type: "boolean", - defaultValue: false, - }); - const supportTier = entitlements.feature("support-tier", { - type: "string", - defaultValue: "basic", - }); - const seats = entitlements.feature("licensed-seats", { - type: "number", - defaultValue: 0, - }); + 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); @@ -99,12 +85,21 @@ describe("Feature", () => { }); }); + 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", { - type: "object", - defaultValue: {} as { featureId: string; value?: string }, - }); + const supportTier = entitlements.feature( + "support-tier", + {} as { featureId: string; value?: string }, + ); const details = await supportTier.getDetails(target); @@ -118,10 +113,7 @@ describe("Feature", () => { }); it("exposes the full resolution via getDetails", async () => { - const seats = makeEntitlements().feature("licensed-seats", { - type: "number", - defaultValue: 0, - }); + const seats = makeEntitlements().feature("licensed-seats", 0); const details = await seats.getDetails(target); @@ -134,10 +126,7 @@ describe("Feature", () => { it("falls back to the default value when the feature is missing", async () => { setDefaultEntitlements(makeEntitlements()); - const missing = new Feature("does-not-exist", { - type: "number", - defaultValue: 7, - }); + const missing = new Feature("does-not-exist", 7); await expect(missing.get(target)).resolves.toBe(7); await expect(missing.getDetails(target)).resolves.toMatchObject({ @@ -146,49 +135,52 @@ describe("Feature", () => { }); }); - it("prefers an explicit client over the bound and default clients", async () => { + it("prefers a bound client over the default one", async () => { const calls: string[] = []; - const stub = (name: string): EntitlementsEvaluator => ({ - getBooleanValue: async () => ({ value: false }), - getStringValue: async () => ({ value: "" }), - getObjectValue: async () => ({ value: undefined as T }), - getNumberValue: async () => { + const stub = (name: string, value: number): EntitlementsClient => ({ + getValue: async () => { calls.push(name); - return { value: name === "override" ? 99 : 1 }; + return { value: value as never }; }, }); - setDefaultEntitlements(stub("default")); + setDefaultEntitlements(stub("default", 1)); - const seats = new Feature("licensed-seats", { - type: "number", - defaultValue: 0, - client: stub("bound"), - }); + const seats = new Feature("licensed-seats", 0, stub("bound", 2)); - await expect( - seats.get(target, { client: stub("override") }), - ).resolves.toBe(99); - expect(calls).toEqual(["override"]); + await expect(seats.get(target)).resolves.toBe(2); + expect(calls).toEqual(["bound"]); }); - it("binds a copy to a client with withClient", async () => { - const entitlements = makeEntitlements(); - const seats = new Feature("licensed-seats", { - type: "number", - defaultValue: 0, - }).withClient(entitlements); + 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", { - type: "number", - defaultValue: 0, - }); + 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 index 7101137..a250703 100644 --- a/packages/entitlements/test/server.test.ts +++ b/packages/entitlements/test/server.test.ts @@ -5,10 +5,13 @@ import { } from "../src/cache"; import { ChargebeeEntitlements, - type ChargebeeEntitlementsClient, createEntitlementsRelayHandler, } from "../src/server"; -import type { ChargebeeEntitlementsSnapshot, EvaluationContextLike } from "../src/shared"; +import type { ChargebeeEntitlementsClient } from "../src/server/loader"; +import type { + ChargebeeEntitlementsSnapshot, + ChargebeeTarget, +} from "../src/shared"; function makeClient( customerPages: Array<{ @@ -32,10 +35,7 @@ function makeClient( return { client, customerRequest, subscriptionRequest }; } -const context: EvaluationContextLike = { - targetingKey: "app-user-1", - chargebeeCustomerId: "customer-1", -}; +const target: ChargebeeTarget = { customerId: "customer-1" }; /** A durable store keeps snapshots past `expiresAt`; the client refreshes them. */ function makeDurableStore(): EntitlementsStorage { @@ -99,8 +99,8 @@ describe("ChargebeeEntitlements", () => { cacheTtlMs: 60_000, }); - const first = await entitlements.getBooleanValue("sso", false, context); - const second = await entitlements.getNumberValue("seats", 0, context); + 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" }); @@ -142,8 +142,8 @@ describe("ChargebeeEntitlements", () => { } as unknown as ChargebeeEntitlementsClient, }); - const first = entitlements.getSnapshot(context); - const second = entitlements.getSnapshot(context); + const first = entitlements.getSnapshot(target); + const second = entitlements.getSnapshot(target); release?.(); await expect(Promise.all([first, second])).resolves.toHaveLength(2); @@ -175,12 +175,12 @@ describe("ChargebeeEntitlements", () => { } as unknown as ChargebeeEntitlementsClient, }); - const first = entitlements.getSnapshot(context); + const first = entitlements.getSnapshot(target); await started; - await entitlements.deleteSnapshot({ mode: "customer", customerId: "customer-1" }); + await entitlements.deleteSnapshot(target); release?.(); await first; - await entitlements.getSnapshot(context); + await entitlements.getSnapshot(target); expect(customerRequest).toHaveBeenCalledTimes(2); }); @@ -192,7 +192,18 @@ describe("ChargebeeEntitlements", () => { }); await expect( - entitlements.getBooleanValue("sso", false, { targetingKey: "app-user-1" }), + 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", @@ -229,10 +240,7 @@ describe("ChargebeeEntitlements", () => { }); await expect( - entitlements.getNumberValue("seats", 0, { - chargebeeEvaluationMode: "subscription", - chargebeeSubscriptionId: "subscription-1", - }), + entitlements.getValue("seats", 0, { subscriptionId: "subscription-1" }), ).resolves.toMatchObject({ value: 50, flagMetadata: { @@ -246,15 +254,16 @@ describe("ChargebeeEntitlements", () => { ); }); - it("evaluates directly against an explicit ChargebeeTarget", async () => { + 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.getBooleanValue("sso", false, { - mode: "customer", - customerId: "customer-1", - }), + entitlements.getValue("sso", false, context), ).resolves.toMatchObject({ value: true, reason: "TARGETING_MATCH" }); }); @@ -264,20 +273,20 @@ describe("ChargebeeEntitlements", () => { const store = createMemoryEntitlementsCache(); await new ChargebeeEntitlements({ chargebeeClient: client, - store, - }).refreshSnapshot({ mode: "customer", customerId: "customer-1" }); + durableStore: store, + }).refreshSnapshot(target); const reader = new ChargebeeEntitlements({ chargebeeClient: client, cache, - store, + durableStore: store, refreshOnMiss: "background", }); - await expect(reader.getSnapshot(context)).resolves.toMatchObject({ + await expect(reader.getSnapshot(target)).resolves.toMatchObject({ source: "store", }); - await expect(reader.getSnapshot(context)).resolves.toMatchObject({ + await expect(reader.getSnapshot(target)).resolves.toMatchObject({ source: "cache", }); expect(customerRequest).toHaveBeenCalledTimes(1); @@ -296,16 +305,13 @@ describe("ChargebeeEntitlements", () => { set: vi.fn(async () => undefined), delete: vi.fn(async () => undefined), }, - store, + durableStore: store, onError, }); - await entitlements.refreshSnapshot({ - mode: "customer", - customerId: "customer-1", - }); + await entitlements.refreshSnapshot(target); await expect( - entitlements.getBooleanValue("sso", false, context), + entitlements.getValue("sso", false, target), ).resolves.toMatchObject({ value: true, reason: "CACHED", @@ -313,7 +319,7 @@ describe("ChargebeeEntitlements", () => { }); expect(onError).toHaveBeenCalledWith(expect.any(Error), { operation: "cache-read", - target: { mode: "customer", customerId: "customer-1" }, + target: { customerId: "customer-1" }, }); }); @@ -325,12 +331,11 @@ describe("ChargebeeEntitlements", () => { const entitlements = new ChargebeeEntitlements({ chargebeeClient: client, cache, - store, + durableStore: store, }); - const target = { mode: "customer", customerId: "customer-1" } as const; await entitlements.refreshSnapshot(target); - await entitlements.getSnapshot(context); + await entitlements.getSnapshot(target); await entitlements.refreshSnapshot(target); expect(evict).toHaveBeenCalledTimes(2); @@ -378,14 +383,13 @@ describe("ChargebeeEntitlements", () => { }, } as unknown as ChargebeeEntitlementsClient, cache: createMemoryEntitlementsCache(), - store: makeDurableStore(), + durableStore: makeDurableStore(), refreshOnMiss: "background", onSnapshotRefreshed, }); - const target = { mode: "customer", customerId: "customer-1" } as const; await expect( - entitlements.getBooleanValue("sso", false, context), + entitlements.getValue("sso", false, target), ).resolves.toMatchObject({ reason: "STALE" }); await vi.waitFor(() => expect(customerRequest).toHaveBeenCalledTimes(1)); @@ -403,7 +407,7 @@ describe("ChargebeeEntitlements", () => { expect(onSnapshotRefreshed).toHaveBeenCalledWith( expect.objectContaining({ trigger: "explicit" }), ); - await expect(entitlements.getSnapshot(context)).resolves.toMatchObject({ + await expect(entitlements.getSnapshot(target)).resolves.toMatchObject({ snapshot: { entitlements: { sso: expect.objectContaining({ value: "false" }) }, }, @@ -415,14 +419,12 @@ describe("ChargebeeEntitlements", () => { const onSnapshotRefreshed = vi.fn(); const entitlements = new ChargebeeEntitlements({ chargebeeClient: client, - store: createMemoryEntitlementsCache(), + durableStore: createMemoryEntitlementsCache(), refreshOnMiss: "background", onSnapshotRefreshed, }); - await expect( - entitlements.getBooleanValue("sso", false, context), - ).resolves.toEqual({ + await expect(entitlements.getValue("sso", false, target)).resolves.toEqual({ value: false, reason: "STALE", flagMetadata: { snapshotPending: true }, @@ -434,7 +436,7 @@ describe("ChargebeeEntitlements", () => { ), ); await expect( - entitlements.getBooleanValue("sso", false, context), + entitlements.getValue("sso", false, target), ).resolves.toMatchObject({ value: true, reason: "CACHED" }); expect(customerRequest).toHaveBeenCalledTimes(1); }); @@ -443,17 +445,14 @@ describe("ChargebeeEntitlements", () => { const { client, customerRequest } = makeClient([ssoPage]); const entitlements = new ChargebeeEntitlements({ chargebeeClient: client, - store: makeDurableStore(), + durableStore: makeDurableStore(), snapshotTtlMs: 1, refreshOnMiss: "background", }); - await entitlements.refreshSnapshot({ - mode: "customer", - customerId: "customer-1", - }); + await entitlements.refreshSnapshot(target); await new Promise((resolve) => setTimeout(resolve, 5)); - await expect(entitlements.getSnapshot(context)).resolves.toMatchObject({ + await expect(entitlements.getSnapshot(target)).resolves.toMatchObject({ source: "store", }); await vi.waitFor(() => expect(customerRequest).toHaveBeenCalledTimes(2)); @@ -472,21 +471,38 @@ describe("ChargebeeEntitlements", () => { }, } as unknown as ChargebeeEntitlementsClient, refreshOnMiss: "background", - refreshBackoffMs: 60_000, onError, + advanced: { refreshBackoffMs: 60_000 }, }); - await entitlements.getBooleanValue("sso", false, context); + await entitlements.getValue("sso", false, target); await vi.waitFor(() => expect(onError).toHaveBeenCalledWith( expect.any(Error), expect.objectContaining({ operation: "refresh" }), ), ); - await entitlements.getBooleanValue("sso", false, context); + 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", () => { @@ -495,16 +511,14 @@ describe("entitlement relay", () => { const entitlements = new ChargebeeEntitlements({ chargebeeClient: client, }); - const resolveContext = vi.fn(async () => context); + const resolveContext = vi.fn(async () => target); const handler = createEntitlementsRelayHandler({ entitlements, resolveContext, }); const spoofed = await handler( - new Request( - "https://example.com/api/entitlements?chargebeeCustomerId=other", - ), + new Request("https://example.com/api/entitlements?customerId=other"), ); expect(spoofed.status).toBe(400); expect(resolveContext).not.toHaveBeenCalled(); @@ -538,7 +552,7 @@ describe("entitlement relay", () => { it("rejects methods other than GET", async () => { const { client } = makeClient([]); - const resolveContext = vi.fn(async () => context); + const resolveContext = vi.fn(async () => target); const handler = createEntitlementsRelayHandler({ entitlements: new ChargebeeEntitlements({ chargebeeClient: client, diff --git a/packages/entitlements/test/shared.test.ts b/packages/entitlements/test/shared.test.ts index 5165b2d..961c569 100644 --- a/packages/entitlements/test/shared.test.ts +++ b/packages/entitlements/test/shared.test.ts @@ -1,20 +1,22 @@ +import type { ChargebeeEntitlement } from "../src/shared"; import { + assertTarget, createEntitlementsSnapshot, - getTargetFromContext, - resolveBooleanEntitlement, - resolveNumberEntitlement, - resolveObjectEntitlement, - resolveStringEntitlement, + isSnapshotExpired, + parseEntitlementsSnapshot, + parseSerializedEntitlementsSnapshot, + resolveEntitlement, + serializeEntitlementsSnapshot, } from "../src/shared"; const now = Date.UTC(2026, 0, 1); const snapshot = createEntitlementsSnapshot( - "customer", [ { 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 }, @@ -33,36 +35,29 @@ const snapshot = createEntitlementsSnapshot( describe("entitlement mapping", () => { it("maps switch entitlements to booleans", () => { expect( - resolveBooleanEntitlement(snapshot, "switch-on", false, "api"), + resolveEntitlement(snapshot, "switch-on", false, "api"), ).toMatchObject({ value: true, variant: "enabled", reason: "TARGETING_MATCH", }); expect( - resolveBooleanEntitlement(snapshot, "switch-off", true, "cache"), - ).toMatchObject({ - value: false, - reason: "CACHED", - }); + resolveEntitlement(snapshot, "switch-off", true, "cache"), + ).toMatchObject({ value: false, reason: "CACHED" }); expect( - resolveBooleanEntitlement(snapshot, "switch-enabled", false, "api"), - ).toMatchObject({ - value: true, - variant: "enabled", - }); + 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(resolveNumberEntitlement(snapshot, "seats", 0, "store")).toMatchObject( - { - value: 25, - reason: "CACHED", - }, - ); - expect( - resolveNumberEntitlement(snapshot, "storage", 0, "relay"), - ).toMatchObject({ + 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 }, @@ -71,67 +66,138 @@ describe("entitlement mapping", () => { it("maps string and object entitlements", () => { expect( - resolveStringEntitlement(snapshot, "support", "basic", "api"), - ).toMatchObject({ - value: "priority", - variant: "priority", - }); + resolveEntitlement(snapshot, "support", "basic", "api"), + ).toMatchObject({ value: "priority", variant: "priority" }); expect( - resolveObjectEntitlement(snapshot, "support", {}, "api").value, - ).toMatchObject({ - featureId: "support", - value: "priority", - }); + resolveEntitlement>( + snapshot, + "support", + {}, + "api", + ).value, + ).toMatchObject({ featureId: "support", value: "priority" }); }); - it("fails closed for disabled, expired, missing, and mismatched values", () => { - expect( - resolveBooleanEntitlement(snapshot, "disabled", false, "api"), - ).toMatchObject({ - value: false, - reason: "DISABLED", - }); + 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( - resolveBooleanEntitlement(snapshot, "expired", false, "api"), - ).toMatchObject({ + 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( - resolveBooleanEntitlement(snapshot, "missing", false, "api"), - ).toMatchObject({ + expect(resolveEntitlement(snapshot, "missing", false, "api")).toMatchObject({ value: false, errorCode: "FLAG_NOT_FOUND", }); - expect( - resolveBooleanEntitlement(snapshot, "support", false, "api"), - ).toMatchObject({ + 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 context", () => { - it("resolves explicit customer and subscription targets", () => { - expect(getTargetFromContext({ chargebeeCustomerId: "customer-1" })).toEqual({ - mode: "customer", +describe("target validation", () => { + it("reduces a target to the single identifier it evaluates against", () => { + expect(assertTarget({ customerId: "customer-1" })).toEqual({ customerId: "customer-1", }); - expect( - getTargetFromContext({ - chargebeeEvaluationMode: "subscription", - chargebeeSubscriptionId: "subscription-1", - }), - ).toEqual({ - mode: "subscription", + expect(assertTarget({ subscriptionId: "subscription-1" })).toEqual({ subscriptionId: "subscription-1", }); }); - it("does not treat targetingKey as a Chargebee identifier", () => { - expect(() => getTargetFromContext({ targetingKey: "app-user-1" })).toThrow( - "chargebeeCustomerId", + 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 index a2d0ee9..36480c2 100644 --- a/packages/entitlements/test/web.test.ts +++ b/packages/entitlements/test/web.test.ts @@ -1,10 +1,17 @@ -import { createEntitlementsSnapshot } from "../src/shared"; +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( - "customer", [{ featureId: "sso", value: "true", isEnabled: true }], 60_000, ); @@ -16,9 +23,7 @@ describe("ChargebeeEntitlementsWebClient", () => { await client.initialize(); - expect(client.getBooleanValue("sso", false)).toMatchObject({ - value: true, - }); + expect(client.getValue("sso", false)).toMatchObject({ value: true }); expect(fetchImplementation).toHaveBeenCalledWith( "/api/entitlements", expect.objectContaining({ @@ -30,7 +35,6 @@ describe("ChargebeeEntitlementsWebClient", () => { it("fails closed when the relay snapshot expires", async () => { const snapshot = createEntitlementsSnapshot( - "customer", [{ featureId: "sso", value: "true", isEnabled: true }], 500, Date.now() - 1_000, @@ -43,7 +47,7 @@ describe("ChargebeeEntitlementsWebClient", () => { }); await client.initialize(); - expect(client.getBooleanValue("sso", false)).toMatchObject({ + expect(client.getValue("sso", false)).toMatchObject({ value: false, reason: "STALE", }); @@ -52,12 +56,10 @@ describe("ChargebeeEntitlementsWebClient", () => { it("refreshes its snapshot on reset", async () => { const first = createEntitlementsSnapshot( - "customer", [{ featureId: "sso", value: "false", isEnabled: true }], 60_000, ); const second = createEntitlementsSnapshot( - "customer", [{ featureId: "sso", value: "true", isEnabled: true }], 60_000, ); @@ -73,15 +75,14 @@ describe("ChargebeeEntitlementsWebClient", () => { }); await client.initialize(); - expect(client.getBooleanValue("sso", true).value).toBe(false); + expect(client.getValue("sso", true).value).toBe(false); await client.reset(); - expect(client.getBooleanValue("sso", false).value).toBe(true); + 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( - "customer", [{ featureId: "sso", value: "true", isEnabled: true }], 60_000, ); @@ -101,9 +102,36 @@ describe("ChargebeeEntitlementsWebClient", () => { await client.initialize(); await expect(client.reset()).rejects.toThrow("HTTP 401"); expect(onError).toHaveBeenCalledWith(expect.stringContaining("HTTP 401")); - expect(client.getBooleanValue("sso", false)).toMatchObject({ + 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/openfeature/CHANGELOG.md b/packages/openfeature/CHANGELOG.md index 7e918ba..f390956 100644 --- a/packages/openfeature/CHANGELOG.md +++ b/packages/openfeature/CHANGELOG.md @@ -24,7 +24,16 @@ from `chargebee:openfeature:v1` to `chargebee:entitlements:v1`; pass `cacheNamespace` explicitly if you need to keep reading previously cached keys. -- Replaced the tiered cache with a `cache` slot in front of a durable `store` +- 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 @@ -37,8 +46,7 @@ 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, - and the new `evictCachedSnapshot` drops the cached copy without fetching, so - a webhook-driven refresh cannot be shadowed by stale cached values. + 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 @@ -62,11 +70,12 @@ implement `EntitlementsStorage` directly. - `@chargebee/openfeature/server` and `@chargebee/openfeature/web` no longer re-export shared domain types (`ChargebeeTarget`, `ChargebeeEntitlement`, - `ChargebeeEntitlementsSnapshot`, `ChargebeeEvaluationMode`, - `CHARGEBEE_CONTEXT_KEYS`, etc.). Import these from the root + `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. + 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. diff --git a/packages/openfeature/README.md b/packages/openfeature/README.md index a2171c4..550c923 100644 --- a/packages/openfeature/README.md +++ b/packages/openfeature/README.md @@ -44,8 +44,9 @@ export const entitlementsProvider = new ChargebeeEntitlementsProvider({ await OpenFeature.setProviderAndWait(entitlementsProvider); ``` -Evaluate Chargebee feature IDs as OpenFeature flag keys. `targetingKey` remains -the application's subject ID and is never assumed to be a Chargebee ID. +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( @@ -53,11 +54,14 @@ const enabled = await OpenFeature.getClient().getBooleanValue( false, { targetingKey: session.user.id, - chargebeeCustomerId: session.user.chargebeeCustomerId, + 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 @@ -74,7 +78,7 @@ import { ChargebeeEntitlementsProvider } from "@chargebee/openfeature/server"; export const entitlements = new ChargebeeEntitlements({ chargebeeClient: chargebee, cache, - store: postgresSnapshotStore, + durableStore: postgresSnapshotStore, }); export const entitlementsProvider = new ChargebeeEntitlementsProvider({ diff --git a/packages/openfeature/src/index.ts b/packages/openfeature/src/index.ts index f741d4e..615d8aa 100644 --- a/packages/openfeature/src/index.ts +++ b/packages/openfeature/src/index.ts @@ -1,18 +1,7 @@ export type { ChargebeeEntitlement, - ChargebeeEntitlementsSnapshot, - ChargebeeEvaluationMode, ChargebeeTarget, - EvaluationContextLike, + EntitlementErrorCode, + EntitlementResolution, Logger, - SnapshotSource, -} from "@chargebee/entitlements"; -export { - CHARGEBEE_CONTEXT_KEYS, - createEntitlementsSnapshot, - getTargetFromContext, - isSnapshotExpired, - parseEntitlementsSnapshot, - parseSerializedEntitlementsSnapshot, - serializeEntitlementsSnapshot, } 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/provider.ts b/packages/openfeature/src/server/provider.ts index f58f3f3..a5417df 100644 --- a/packages/openfeature/src/server/provider.ts +++ b/packages/openfeature/src/server/provider.ts @@ -1,4 +1,4 @@ -import { toResolutionDetails } from "@chargebee/entitlements"; +import type { ChargebeeTarget } from "@chargebee/entitlements"; import { ChargebeeEntitlements, type ChargebeeEntitlementsOptions, @@ -7,15 +7,27 @@ import type { ErrorCode, EvaluationContext, JsonValue, - Logger, 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, @@ -43,66 +55,52 @@ export class ChargebeeEntitlementsProvider implements Provider { return this.entitlements.close(); } - async resolveBooleanEvaluation( + resolveBooleanEvaluation( flagKey: string, defaultValue: boolean, context: EvaluationContext, - logger: Logger, ): Promise> { - return toResolutionDetails( - await this.entitlements.getBooleanValue( - flagKey, - defaultValue, - context, - logger, - ), - ); + return this.resolve(flagKey, defaultValue, context); } - async resolveStringEvaluation( + resolveStringEvaluation( flagKey: string, defaultValue: string, context: EvaluationContext, - logger: Logger, ): Promise> { - return toResolutionDetails( - await this.entitlements.getStringValue( - flagKey, - defaultValue, - context, - logger, - ), - ); + return this.resolve(flagKey, defaultValue, context); } - async resolveNumberEvaluation( + resolveNumberEvaluation( flagKey: string, defaultValue: number, context: EvaluationContext, - logger: Logger, ): Promise> { - return toResolutionDetails( - await this.entitlements.getNumberValue( - flagKey, - defaultValue, - context, - logger, - ), - ); + return this.resolve(flagKey, defaultValue, context); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + ): Promise> { + return this.resolve(flagKey, defaultValue, context); } - async resolveObjectEvaluation( + /** + * 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, - logger: Logger, ): Promise> { return toResolutionDetails( - await this.entitlements.getObjectValue( + await this.entitlements.getValue( flagKey, defaultValue, - context, - logger, + targetFrom(context), ), ); } diff --git a/packages/openfeature/src/web/provider.ts b/packages/openfeature/src/web/provider.ts index 2725cb0..b649e12 100644 --- a/packages/openfeature/src/web/provider.ts +++ b/packages/openfeature/src/web/provider.ts @@ -1,4 +1,3 @@ -import { toResolutionDetails } from "@chargebee/entitlements"; import { ChargebeeEntitlementsWebClient, type ChargebeeEntitlementsWebClientOptions, @@ -7,12 +6,12 @@ import { type ErrorCode, type EvaluationContext, type JsonValue, - type Logger, OpenFeatureEventEmitter, type Provider, ProviderEvents, type ResolutionDetails, } from "@openfeature/web-sdk"; +import { toResolutionDetails } from "../resolution"; export type ChargebeeEntitlementsWebProviderOptions = Omit< ChargebeeEntitlementsWebClientOptions, @@ -69,44 +68,39 @@ export class ChargebeeEntitlementsWebProvider implements Provider { resolveBooleanEvaluation( flagKey: string, defaultValue: boolean, - _context: EvaluationContext, - _logger: Logger, ): ResolutionDetails { - return toResolutionDetails( - this.client.getBooleanValue(flagKey, defaultValue), - ); + return this.resolve(flagKey, defaultValue); } resolveStringEvaluation( flagKey: string, defaultValue: string, - _context: EvaluationContext, - _logger: Logger, ): ResolutionDetails { - return toResolutionDetails( - this.client.getStringValue(flagKey, defaultValue), - ); + return this.resolve(flagKey, defaultValue); } resolveNumberEvaluation( flagKey: string, defaultValue: number, - _context: EvaluationContext, - _logger: Logger, ): ResolutionDetails { - return toResolutionDetails( - this.client.getNumberValue(flagKey, defaultValue), - ); + return this.resolve(flagKey, defaultValue); } resolveObjectEvaluation( flagKey: string, defaultValue: T, - _context: EvaluationContext, - _logger: Logger, ): 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.getObjectValue(flagKey, defaultValue), + this.client.getValue(flagKey, defaultValue), ); } } diff --git a/packages/openfeature/test/server.test.ts b/packages/openfeature/test/server.test.ts index 31edafb..a13757f 100644 --- a/packages/openfeature/test/server.test.ts +++ b/packages/openfeature/test/server.test.ts @@ -1,6 +1,6 @@ import { ChargebeeEntitlements, - type ChargebeeEntitlementsClient, + type ChargebeeEntitlementsOptions, } from "@chargebee/entitlements/server"; import { OpenFeature } from "@openfeature/server-sdk"; import { ChargebeeEntitlementsProvider } from "../src/server"; @@ -25,13 +25,13 @@ function makeClient() { list: [], })), }, - } as unknown as ChargebeeEntitlementsClient; + } as unknown as ChargebeeEntitlementsOptions["chargebeeClient"]; return { client, customerRequest }; } const context = { targetingKey: "app-user-1", - chargebeeCustomerId: "customer-1", + customerId: "customer-1", }; afterEach(async () => { @@ -60,7 +60,7 @@ describe("ChargebeeEntitlementsProvider", () => { expect(provider.entitlements).toBe(entitlements); await expect( - provider.resolveBooleanEvaluation("sso", false, context, console), + provider.resolveBooleanEvaluation("sso", false, context), ).resolves.toMatchObject({ value: true, reason: "TARGETING_MATCH" }); }); @@ -83,20 +83,50 @@ describe("ChargebeeEntitlementsProvider", () => { subscriptionEntitlement: { subscriptionEntitlementsForSubscription: vi.fn(), }, - } as unknown as ChargebeeEntitlementsClient, + } as unknown as ChargebeeEntitlementsOptions["chargebeeClient"], }); await expect( - provider.resolveNumberEvaluation("seats", 0, context, console), + provider.resolveNumberEvaluation("seats", 0, context), ).resolves.toMatchObject({ value: 10 }); await expect( - provider.resolveStringEvaluation("seats", "0", context, console), + provider.resolveStringEvaluation("seats", "0", context), ).resolves.toMatchObject({ value: "10" }); await expect( - provider.resolveObjectEvaluation("seats", {}, context, console), + 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({ @@ -105,7 +135,7 @@ describe("ChargebeeEntitlementsProvider", () => { }); await expect( - provider.resolveBooleanEvaluation("sso", false, context, console), + provider.resolveBooleanEvaluation("sso", false, context), ).resolves.toEqual({ value: false, reason: "STALE", @@ -120,12 +150,15 @@ describe("ChargebeeEntitlementsProvider", () => { }); await expect( - provider.resolveBooleanEvaluation( - "sso", - false, - { targetingKey: "app-user-1" }, - console, - ), + 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(); }); diff --git a/packages/openfeature/test/web.test.ts b/packages/openfeature/test/web.test.ts index 71f7b2e..4f4bf91 100644 --- a/packages/openfeature/test/web.test.ts +++ b/packages/openfeature/test/web.test.ts @@ -1,4 +1,4 @@ -import { createEntitlementsSnapshot } from "@chargebee/entitlements"; +import { createEntitlementsSnapshot } from "@chargebee/entitlements/server"; import { OpenFeature, ProviderEvents } from "@openfeature/web-sdk"; import { ChargebeeEntitlementsWebProvider } from "../src/web"; @@ -9,7 +9,6 @@ afterEach(async () => { describe("ChargebeeEntitlementsWebProvider", () => { it("loads a relay snapshot and evaluates synchronously through the web SDK", async () => { const snapshot = createEntitlementsSnapshot( - "customer", [{ featureId: "sso", value: "true", isEnabled: true }], 60_000, ); @@ -33,7 +32,6 @@ describe("ChargebeeEntitlementsWebProvider", () => { it("emits Stale and falls back to the default while the relay snapshot expires", async () => { const snapshot = createEntitlementsSnapshot( - "customer", [{ featureId: "sso", value: "true", isEnabled: true }], 500, Date.now() - 1_000, @@ -46,20 +44,19 @@ describe("ChargebeeEntitlementsWebProvider", () => { provider.events.addHandler(ProviderEvents.Stale, onStale); await provider.initialize(); - expect( - provider.resolveBooleanEvaluation("sso", false, {}, console), - ).toMatchObject({ value: false, reason: "STALE" }); + 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( - "customer", [{ featureId: "sso", value: "false", isEnabled: true }], 60_000, ); const second = createEntitlementsSnapshot( - "customer", [{ featureId: "sso", value: "true", isEnabled: true }], 60_000, ); @@ -78,13 +75,9 @@ describe("ChargebeeEntitlementsWebProvider", () => { ); await provider.initialize(); - expect( - provider.resolveBooleanEvaluation("sso", true, {}, console).value, - ).toBe(false); + expect(provider.resolveBooleanEvaluation("sso", true).value).toBe(false); await provider.onContextChange({}, { targetingKey: "new-user" }); - expect( - provider.resolveBooleanEvaluation("sso", false, {}, console).value, - ).toBe(true); + expect(provider.resolveBooleanEvaluation("sso", false).value).toBe(true); expect(onConfigurationChanged).toHaveBeenCalledWith( expect.objectContaining({ flagsChanged: ["sso"] }), ); @@ -92,7 +85,6 @@ describe("ChargebeeEntitlementsWebProvider", () => { it("does not retain the previous subject's snapshot after a failed context change", async () => { const snapshot = createEntitlementsSnapshot( - "customer", [{ featureId: "sso", value: "true", isEnabled: true }], 60_000, ); @@ -114,9 +106,7 @@ describe("ChargebeeEntitlementsWebProvider", () => { { targetingKey: "second-user" }, ), ).rejects.toThrow("HTTP 401"); - expect( - provider.resolveBooleanEvaluation("sso", false, {}, console), - ).toMatchObject({ + expect(provider.resolveBooleanEvaluation("sso", false)).toMatchObject({ value: false, errorCode: "PROVIDER_NOT_READY", }); @@ -126,9 +116,7 @@ describe("ChargebeeEntitlementsWebProvider", () => { const provider = new ChargebeeEntitlementsWebProvider({ relayUrl: "/api/entitlements", fetchImplementation: async () => - Response.json( - createEntitlementsSnapshot("customer", [], 60_000), - ), + Response.json(createEntitlementsSnapshot([], 60_000)), }); const close = vi.spyOn(provider.client, "close"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c372550..2f0dae2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,9 +104,6 @@ importers: server-only: specifier: ^0.0.1 version: 0.0.1 - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@arethetypeswrong/cli': specifier: ^0.18.5 From 2b728c8e297c745a8760b74088e5e8942ddbd05b Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Tue, 25 Aug 2026 13:55:51 +1000 Subject: [PATCH 14/22] agents.md --- AGENTS.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 AGENTS.md 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. From 739512730240ca273d0e365feae92f992c8adf9e Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Mon, 31 Aug 2026 21:37:32 +1000 Subject: [PATCH 15/22] bump entitlements@0.1.0-alpha.2 --- packages/entitlements/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/entitlements/package.json b/packages/entitlements/package.json index 4707ce1..3011734 100644 --- a/packages/entitlements/package.json +++ b/packages/entitlements/package.json @@ -1,7 +1,7 @@ { "name": "@chargebee/entitlements", "author": "DX Chargebee", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.2", "type": "module", "description": "Framework-agnostic Chargebee entitlements resolution", "license": "MIT", From 126a55354de2141b34fd74bf65add68abf23af26 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Tue, 6 Oct 2026 19:26:48 +1100 Subject: [PATCH 16/22] bump versions --- packages/better-auth/CHANGELOG.md | 12 + packages/better-auth/README.md | 67 ++++ packages/better-auth/package.json | 9 +- packages/better-auth/src/client.ts | 2 + packages/better-auth/src/entitlements.ts | 209 +++++++++++ packages/better-auth/src/error-codes.ts | 2 + packages/better-auth/src/index.ts | 7 + packages/better-auth/src/routes.ts | 53 +++ packages/better-auth/src/types.ts | 29 +- .../better-auth/test/entitlements.test.ts | 292 +++++++++++++++ packages/entitlements/CHANGELOG.md | 32 ++ packages/entitlements/README.md | 348 +++++------------- packages/entitlements/package.json | 5 +- packages/entitlements/src/index.ts | 7 +- .../entitlements/src/server/entitlements.ts | 70 ++-- packages/entitlements/src/server/index.ts | 1 + packages/entitlements/src/server/scoped.ts | 52 +++ .../entitlements/src/shared/evaluation.ts | 222 ++++------- packages/entitlements/src/shared/feature.ts | 77 +++- packages/entitlements/src/shared/index.ts | 8 +- packages/entitlements/src/shared/target.ts | 8 +- packages/entitlements/src/shared/types.ts | 58 ++- packages/entitlements/src/web/client.ts | 191 ++++++---- packages/entitlements/src/web/index.ts | 6 +- packages/entitlements/test/feature.test.ts | 64 +++- packages/entitlements/test/server.test.ts | 130 +++++-- packages/entitlements/test/shared.test.ts | 34 +- packages/entitlements/test/web.test.ts | 169 ++++++--- packages/openfeature/CHANGELOG.md | 8 + packages/openfeature/package.json | 2 +- packages/openfeature/src/index.ts | 3 +- packages/openfeature/src/resolution.ts | 161 +++++++- packages/openfeature/src/server/provider.ts | 9 +- packages/openfeature/src/web/provider.ts | 54 +-- packages/openfeature/test/server.test.ts | 31 ++ packages/openfeature/test/web.test.ts | 2 +- pnpm-lock.yaml | 3 + 37 files changed, 1750 insertions(+), 687 deletions(-) create mode 100644 packages/better-auth/src/entitlements.ts create mode 100644 packages/better-auth/test/entitlements.test.ts create mode 100644 packages/entitlements/src/server/scoped.ts diff --git a/packages/better-auth/CHANGELOG.md b/packages/better-auth/CHANGELOG.md index 2a07f65..2eb33e8 100644 --- a/packages/better-auth/CHANGELOG.md +++ b/packages/better-auth/CHANGELOG.md @@ -1,3 +1,15 @@ +### Unreleased +* * * + +### Feature: +- Added entitlements, backed by `@chargebee/entitlements` (optional peer dependency). Pass a `ChargebeeEntitlements` client as the new `entitlements` option. +- Added `auth.api.hasAccess` (`POST /entitlements/has-access`) for switch features and `auth.api.getEntitlements` (`GET /entitlements/list`), also on the client plugin. +- Added `entitlementsMiddleware(options)`, which puts `hasAccess` and `getEntitlements` on `ctx.context.entitlements` in Better Auth endpoints. +- Targets default to the user's or active organization's Chargebee customer. `subscriptionId` targets one subscription owned by the reference. +- Added the `read-entitlements` `authorizeReference` action, the `ENTITLEMENTS_NOT_CONFIGURED` error code, and the `EntitlementsAccess` and `BooleanFeature` types. + +--- + ### v1.2.0 (2026-07-10) * * * diff --git a/packages/better-auth/README.md b/packages/better-auth/README.md index 0bf55a5..11c3f74 100644 --- a/packages/better-auth/README.md +++ b/packages/better-auth/README.md @@ -17,6 +17,7 @@ The Chargebee plugin integrates [Chargebee's](https://www.chargebee.com) subscri - Team subscription support with seats management - Hosted checkout and portal via Chargebee Hosted Pages - Self-service billing portal for managing payment methods, invoices, and subscriptions +- Feature gating with Chargebee entitlements (`hasAccess`, `getEntitlements`) ## Requirements @@ -568,6 +569,71 @@ await authClient.subscription.cancel({ }); ``` +### Entitlements + +Gate features on the signed-in user's or organization's [Chargebee entitlements](https://www.chargebee.com/docs/billing/2.0/entitlements/entitlements), backed by [`@chargebee/entitlements`](https://github.com/chargebee/js-framework-adapters/blob/main/packages/entitlements/README.md) and its caching: + +```bash +pnpm add @chargebee/entitlements +``` + +```ts +import { ChargebeeEntitlements } from "@chargebee/entitlements/server" + +chargebee({ + chargebeeClient, + entitlements: new ChargebeeEntitlements({ chargebeeClient }), +}) +``` + +`hasAccess` takes switch features only, because other feature types need usage tracking to decide access. It returns `true` only when Chargebee grants the feature. Missing, disabled, and unavailable entitlements return the feature's default, which is `false` for a plain feature ID. `getEntitlements` returns every entitlement of the reference. + +**Server:** + +```ts +const { hasAccess } = await auth.api.hasAccess({ + headers, + body: { featureId: "advanced-reports" }, +}) +const entitlements = await auth.api.getEntitlements({ headers }) +``` + +**Client:** + +```ts +const { data } = await authClient.entitlements.hasAccess({ featureId: "advanced-reports" }) +const { data: entitlements } = await authClient.entitlements.list() +``` + +**Better Auth endpoints and plugins:** `entitlementsMiddleware` adds `ctx.context.entitlements` for the request: + +```ts +import { Feature } from "@chargebee/entitlements" +import { entitlementsMiddleware } from "@chargebee/better-auth" + +const reports = new Feature("advanced-reports", false) + +createAuthEndpoint("/reports", { method: "GET", use: [entitlementsMiddleware(chargebeeOptions)] }, async (ctx) => { + if (!(await ctx.context.entitlements.hasAccess(reports))) { + throw new APIError("FORBIDDEN") + } + return ctx.json(await ctx.context.entitlements.getEntitlements()) +}) +``` + +All three accept the same optional fields as the subscription routes: + +| Field | Target | +| ---------------- | ------------------------------------------------------------------------------------------------------------ | +| none | The user's Chargebee customer, consolidated across their subscriptions. | +| `customerType` | `"organization"` targets the active organization's customer. Requires `authorizeReference`. | +| `referenceId` | Another user or organization, authorized with `authorizeReference` and the `read-entitlements` action. | +| `subscriptionId` | That Chargebee subscription. Rejected with `403` unless it belongs to the reference. | + +A reference without a Chargebee customer has no entitlements. + +> **Note:** Subscription webhooks don't refresh entitlement snapshots yet, so plan changes take effect after `snapshotTtlMs` (5 minutes by default). To apply them sooner, call `entitlements.refreshSnapshot({ customerId })` from your own `webhookHandler`. + ### Webhook Handling The plugin automatically processes common webhook events from Chargebee: @@ -776,6 +842,7 @@ chargebee({ | `getCustomerCreateParams`| `function` | Return additional params for `cb.customer.create` (e.g. `first_name`, `last_name`). Receives `user` and optional `ctx`. | | `onCustomerCreate` | `function` | Callback called after a customer is created. Receives `{ chargebeeCustomer, user }`. | | `webhookHandler` | `function` | Callback receiving the webhook handler instance. Call `handler.on(EventType, fn)` to register typed event listeners. | +| `entitlements` | `ChargebeeEntitlements` | Entitlements client from `@chargebee/entitlements/server`. See [Entitlements](#entitlements). | | `subscription` | `object` | Subscription configuration. See [Subscription options](#subscription-options). | | `organization` | `object` | Enable Organization Customer support. See [Organization options](#organization-options). | diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index dd6bf5e..0e500c9 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -1,7 +1,7 @@ { "name": "@chargebee/better-auth", "author": "DX Chargebee", - "version": "1.2.0", + "version": "1.3.0-alpha.3", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", @@ -64,12 +64,19 @@ }, "peerDependencies": { "@better-auth/core": "^1.4.18", + "@chargebee/entitlements": "^0.1.0-alpha.2", "better-auth": "^1.4.0", "better-call": "^1.1.7", "chargebee": "^3.23.1" }, + "peerDependenciesMeta": { + "@chargebee/entitlements": { + "optional": true + } + }, "devDependencies": { "@better-auth/core": "^1.5.3", + "@chargebee/entitlements": "workspace:*", "@vitest/coverage-v8": "^4.0.18", "better-auth": "^1.5.3", "better-call": "^1.1.8", diff --git a/packages/better-auth/src/client.ts b/packages/better-auth/src/client.ts index 0846562..d2bfc27 100644 --- a/packages/better-auth/src/client.ts +++ b/packages/better-auth/src/client.ts @@ -37,6 +37,8 @@ export const chargebeeClient = < "/subscription/cancel": "POST", "/subscription/portal": "POST", "/subscription/list": "GET", + "/entitlements/has-access": "POST", + "/entitlements/list": "GET", }, } satisfies BetterAuthClientPlugin; diff --git a/packages/better-auth/src/entitlements.ts b/packages/better-auth/src/entitlements.ts new file mode 100644 index 0000000..a974a44 --- /dev/null +++ b/packages/better-auth/src/entitlements.ts @@ -0,0 +1,209 @@ +import type { ChargebeeTarget } from "@chargebee/entitlements"; +import type { ChargebeeEntitlements } from "@chargebee/entitlements/server"; +import type { GenericEndpointContext } from "better-auth"; +import { APIError, createAuthMiddleware } from "better-auth/api"; +import { CHARGEBEE_ERROR_CODES } from "./error-codes"; +import { referenceMiddleware, sessionMiddleware } from "./middleware"; +import type { + ChargebeeCtxSession, + ChargebeeOptions, + CustomerType, + EntitlementsAccess, + Subscription, + SubscriptionOptions, + WithChargebeeCustomerId, +} from "./types"; +import { getReferenceId } from "./utils"; + +type Adapter = GenericEndpointContext["context"]["adapter"]; + +/** `getSnapshot` throws it while a background refresh loads the first snapshot. */ +const SNAPSHOT_PENDING_ERROR = "SnapshotPendingError"; + +/** Without `authorizeReference`, only the session user's own reference passes. */ +const NO_SUBSCRIPTION_OPTIONS: SubscriptionOptions = { + enabled: false, + plans: [], +}; + +/** Table holding the Chargebee customer ID of each reference type. */ +const CUSTOMER_MODELS: Record = { + user: "user", + organization: "organization", +}; + +/** Request fields picking the billing reference, as on `/subscription/*`. */ +interface TargetInput { + customerType?: CustomerType; + referenceId?: string; + subscriptionId?: string; +} + +/** + * Maps the request's reference to a Chargebee target: + * + * subscriptionId given → { subscriptionId }, if the reference owns it + * otherwise → { customerId } of the user or organization + * no customer yet → undefined, so nothing is granted + */ +async function resolveTarget( + adapter: Adapter, + session: ChargebeeCtxSession, + input: TargetInput, + options: ChargebeeOptions, +): Promise { + const customerType = input.customerType ?? "user"; + const referenceId = + input.referenceId ?? getReferenceId(session, customerType, options); + + if (input.subscriptionId) { + return findSubscription(adapter, referenceId, input.subscriptionId); + } + + const customerId = await findCustomerId( + adapter, + session, + customerType, + referenceId, + ); + if (!customerId) { + return undefined; + } + + return { customerId }; +} + +/** A subscription target, only for a subscription the reference owns. */ +async function findSubscription( + adapter: Adapter, + referenceId: string, + subscriptionId: string, +): Promise { + const subscription = await adapter.findOne({ + model: "subscription", + where: [ + { field: "chargebeeSubscriptionId", value: subscriptionId }, + { field: "referenceId", value: referenceId }, + ], + }); + if (!subscription) { + throw new APIError("FORBIDDEN", { + message: CHARGEBEE_ERROR_CODES.UNAUTHORIZED_REFERENCE.message, + }); + } + + return { subscriptionId }; +} + +/** + * The session user usually carries its customer ID. Other references, and a + * session cached before the customer was created, are read from the database. + */ +async function findCustomerId( + adapter: Adapter, + session: ChargebeeCtxSession, + customerType: CustomerType, + referenceId: string, +): Promise { + const isSessionUser = + customerType === "user" && referenceId === session.user.id; + if (isSessionUser && session.user.chargebeeCustomerId) { + return session.user.chargebeeCustomerId; + } + + const record = await adapter.findOne({ + model: CUSTOMER_MODELS[customerType], + where: [{ field: "id", value: referenceId }], + }); + + return record?.chargebeeCustomerId ?? undefined; +} + +/** Resolves the target on first use, then reuses it for the whole request. */ +function createAccess( + client: ChargebeeEntitlements, + resolve: () => Promise, +): EntitlementsAccess { + let target: Promise | undefined; + const getTarget = () => { + target ??= resolve(); + return target; + }; + + return { + async hasAccess(feature) { + const { featureId, defaultValue } = + typeof feature === "string" + ? { featureId: feature, defaultValue: false } + : feature; + + const resolved = await getTarget(); + if (!resolved) { + return defaultValue; + } + + return client.get(featureId, defaultValue, resolved); + }, + + async getEntitlements() { + const resolved = await getTarget(); + if (!resolved) { + return []; + } + + try { + const { snapshot } = await client.getSnapshot(resolved); + return Object.values(snapshot.entitlements); + } catch (error) { + if (error instanceof Error && error.name === SNAPSHOT_PENDING_ERROR) { + return []; + } + throw error; + } + }, + }; +} + +/** + * Adds `ctx.context.entitlements` to Better Auth endpoints, for the session + * user or the reference picked by `customerType`, `referenceId`, and + * `subscriptionId`, authorized like the `/subscription/*` routes: + * + * ```ts + * createAuthEndpoint("/reports", { method: "GET", use: [entitlementsMiddleware(options)] }, + * async (ctx) => ctx.json({ allowed: await ctx.context.entitlements.hasAccess("reports") })); + * ``` + */ +export const entitlementsMiddleware = (options: ChargebeeOptions) => + createAuthMiddleware( + { + use: [ + sessionMiddleware, + referenceMiddleware( + options.subscription ?? NO_SUBSCRIPTION_OPTIONS, + "read-entitlements", + ), + ], + }, + async (ctx) => { + const client = options.entitlements; + if (!client) { + throw new APIError("BAD_REQUEST", { + message: CHARGEBEE_ERROR_CODES.ENTITLEMENTS_NOT_CONFIGURED.message, + }); + } + + const session = ctx.context.session as ChargebeeCtxSession; + const input: TargetInput = { + customerType: ctx.body?.customerType || ctx.query?.customerType, + referenceId: ctx.body?.referenceId || ctx.query?.referenceId, + subscriptionId: ctx.body?.subscriptionId || ctx.query?.subscriptionId, + }; + + const entitlements = createAccess(client, () => + resolveTarget(ctx.context.adapter, session, input, options), + ); + + return { entitlements }; + }, + ); diff --git a/packages/better-auth/src/error-codes.ts b/packages/better-auth/src/error-codes.ts index df9135c..ff5c30e 100644 --- a/packages/better-auth/src/error-codes.ts +++ b/packages/better-auth/src/error-codes.ts @@ -20,4 +20,6 @@ export const CHARGEBEE_ERROR_CODES = defineErrorCodes({ "Organization subscriptions require authorizeReference callback to be configured", ORGANIZATION_REFERENCE_ID_REQUIRED: "Reference ID is required. Provide referenceId or set activeOrganizationId in session", + ENTITLEMENTS_NOT_CONFIGURED: + "Entitlements require the entitlements option in your chargebee plugin config", }); diff --git a/packages/better-auth/src/index.ts b/packages/better-auth/src/index.ts index 5a6454e..22463e7 100644 --- a/packages/better-auth/src/index.ts +++ b/packages/better-auth/src/index.ts @@ -7,7 +7,9 @@ import { cancelSubscriptionCallback, createPortalSession, createSubscription, + getEntitlements, getWebhookEndpoint, + hasAccess, listActiveSubscriptions, subscriptionSuccess, updateSubscription, @@ -44,6 +46,8 @@ export const chargebee = (options: O) => { cancelSubscriptionCallback: cancelSubscriptionCallback(options), createPortalSession: createPortalSession(options), listActiveSubscriptions: listActiveSubscriptions(options), + hasAccess: hasAccess(options), + getEntitlements: getEntitlements(options), }, options: options as NoInfer, $ERROR_CODES: CHARGEBEE_ERROR_CODES, @@ -208,11 +212,14 @@ export type ChargebeePlugin = ReturnType< // Re-export native Better Auth types for convenience export type { Organization } from "better-auth/plugins/organization"; +export { entitlementsMiddleware } from "./entitlements"; export { CHARGEBEE_ERROR_CODES } from "./error-codes"; export type { + BooleanFeature, ChargebeeOptions, ChargebeePlan, ChargebeeWebhookEventBus, + EntitlementsAccess, Subscription, SubscriptionOptions, SubscriptionStatus, diff --git a/packages/better-auth/src/routes.ts b/packages/better-auth/src/routes.ts index 315a8f9..5b92ae5 100644 --- a/packages/better-auth/src/routes.ts +++ b/packages/better-auth/src/routes.ts @@ -6,6 +6,7 @@ import { } from "better-auth/api"; import type { Organization } from "better-auth/plugins/organization"; import { z } from "zod"; +import { entitlementsMiddleware } from "./entitlements"; import { CHARGEBEE_ERROR_CODES } from "./error-codes"; import { referenceMiddleware, sessionMiddleware } from "./middleware"; import type { @@ -1250,3 +1251,55 @@ export function cancelSubscription(options: ChargebeeOptions) { }, ); } + +/** Fields picking the billing reference, shared by the entitlement endpoints. */ +const entitlementTargetSchema = z.object({ + referenceId: z.string().optional(), + subscriptionId: z.string().optional(), + customerType: z.enum(["user", "organization"]).optional(), +}); + +/** + * Whether the reference is granted a switch feature. Missing, disabled, and + * unavailable entitlements deny access. + */ +export function hasAccess(options: ChargebeeOptions) { + return createAuthEndpoint( + "/entitlements/has-access", + { + method: "POST", + body: entitlementTargetSchema.extend({ featureId: z.string().min(1) }), + metadata: { + openapi: { + operationId: "hasAccess", + }, + }, + use: [entitlementsMiddleware(options)], + }, + async (ctx) => { + const granted = await ctx.context.entitlements.hasAccess( + ctx.body.featureId, + ); + + return ctx.json({ hasAccess: granted }); + }, + ); +} + +/** Every entitlement of the reference; empty without a Chargebee customer. */ +export function getEntitlements(options: ChargebeeOptions) { + return createAuthEndpoint( + "/entitlements/list", + { + method: "GET", + query: entitlementTargetSchema.optional(), + metadata: { + openapi: { + operationId: "getEntitlements", + }, + }, + use: [entitlementsMiddleware(options)], + }, + async (ctx) => ctx.json(await ctx.context.entitlements.getEntitlements()), + ); +} diff --git a/packages/better-auth/src/types.ts b/packages/better-auth/src/types.ts index 2b2e1cd..a764075 100644 --- a/packages/better-auth/src/types.ts +++ b/packages/better-auth/src/types.ts @@ -1,3 +1,9 @@ +import type { + ChargebeeEntitlement, + EntitlementsClient, + Feature, +} from "@chargebee/entitlements"; +import type { ChargebeeEntitlements } from "@chargebee/entitlements/server"; import type { Session, User } from "better-auth"; import type { Organization } from "better-auth/plugins/organization"; import type Chargebee from "chargebee"; @@ -40,7 +46,8 @@ export type AuthorizeReferenceAction = | "list-subscription" | "cancel-subscription" | "restore-subscription" - | "billing-portal"; + | "billing-portal" + | "read-entitlements"; export type WithActiveOrganizationId = { activeOrganizationId?: string; @@ -185,6 +192,12 @@ export interface ChargebeeOptions { * When not set, events are processed synchronously within the request. */ webhookEventBus?: ChargebeeWebhookEventBus; + /** + * Entitlements client backing `hasAccess` and `getEntitlements`, e.g. + * `new ChargebeeEntitlements({ chargebeeClient })` from + * `@chargebee/entitlements/server`. + */ + entitlements?: ChargebeeEntitlements; subscription?: SubscriptionOptions; organization?: { enabled: boolean; @@ -243,3 +256,17 @@ export interface SubscriptionItemRecord { export type WithChargebeeCustomerId = { chargebeeCustomerId?: string; }; + +/** A switch feature: the only kind that resolves to access without usage data. */ +export type BooleanFeature = Feature; + +/** + * Entitlements of the request's billing reference, e.g. + * `await ctx.context.entitlements.hasAccess("advanced-reports")`. + */ +export interface EntitlementsAccess { + /** `true` only when Chargebee grants the feature; otherwise its default. */ + hasAccess(feature: string | BooleanFeature): Promise; + /** Every entitlement of the reference; empty without a Chargebee customer. */ + getEntitlements(): Promise; +} diff --git a/packages/better-auth/test/entitlements.test.ts b/packages/better-auth/test/entitlements.test.ts new file mode 100644 index 0000000..d415563 --- /dev/null +++ b/packages/better-auth/test/entitlements.test.ts @@ -0,0 +1,292 @@ +import { Feature } from "@chargebee/entitlements"; +import { ChargebeeEntitlements } from "@chargebee/entitlements/server"; +import type { BetterAuthPlugin } from "better-auth"; +import { createAuthEndpoint } from "better-auth/api"; +import { organization } from "better-auth/plugins"; +import { getTestInstance } from "better-auth/test"; +import type Chargebee from "chargebee"; +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { chargebee, entitlementsMiddleware } from "../src"; +import { CHARGEBEE_ERROR_CODES } from "../src/error-codes"; +import type { ChargebeeOptions } from "../src/types"; + +const USER_CUSTOMER_ID = "cus_user"; +const ORG_CUSTOMER_ID = "cus_org"; +const SUBSCRIPTION_ID = "sub_1"; + +/** Chargebee answering every customer with `sso` and `seats`, every subscription with `reports`. */ +function makeChargebee() { + const customerRequest = vi.fn(async () => ({ + list: [ + { + customer_entitlement: { + feature_id: "sso", + value: "true", + is_enabled: true, + }, + }, + { + customer_entitlement: { + feature_id: "seats", + value: "10", + is_enabled: true, + }, + }, + ], + })); + const subscriptionRequest = vi.fn(async () => ({ + list: [ + { + subscription_entitlement: { + feature_id: "reports", + value: "true", + is_enabled: true, + }, + }, + ], + })); + const chargebeeClient = { + __clientIdentifier: () => {}, + customerEntitlement: { entitlementsForCustomer: customerRequest }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: subscriptionRequest, + }, + } as unknown as Chargebee; + + return { chargebeeClient, customerRequest, subscriptionRequest }; +} + +/** Test-only endpoint exercising the middleware the way an app would. */ +function probePlugin(options: ChargebeeOptions) { + return { + id: "probe", + endpoints: { + probe: createAuthEndpoint( + "/probe", + { + method: "GET", + query: z.object({ subscriptionId: z.string().optional() }), + use: [entitlementsMiddleware(options)], + }, + async (ctx) => { + const { entitlements } = ctx.context; + const premium = new Feature("premium", true); + + return ctx.json({ + premium: await entitlements.hasAccess(premium), + reports: await entitlements.hasAccess("reports"), + entitlements: await entitlements.getEntitlements(), + }); + }, + ), + }, + } satisfies BetterAuthPlugin; +} + +async function setup(overrides: Partial = {}) { + const chargebeeMock = makeChargebee(); + const options: ChargebeeOptions = { + chargebeeClient: chargebeeMock.chargebeeClient, + entitlements: new ChargebeeEntitlements({ + chargebeeClient: chargebeeMock.chargebeeClient, + }), + subscription: { + enabled: true, + plans: [], + authorizeReference: async () => true, + }, + ...overrides, + }; + + const plugins = [chargebee(options), probePlugin(options)]; + const { auth, signInWithTestUser } = await getTestInstance({ + plugins: options.organization?.enabled + ? [organization(), ...plugins] + : plugins, + }); + const { headers, user } = await signInWithTestUser(); + const context = await auth.$context; + + return { auth, headers, user, context, ...chargebeeMock }; +} + +describe("entitlements - customer target", () => { + it("evaluates the session user's customer", async () => { + const { auth, headers, user, context, customerRequest } = await setup(); + await context.internalAdapter.updateUser(user.id, { + chargebeeCustomerId: USER_CUSTOMER_ID, + }); + + const result = await auth.api.hasAccess({ + headers, + body: { featureId: "sso" }, + }); + + expect(result).toEqual({ hasAccess: true }); + expect(customerRequest).toHaveBeenCalledWith( + USER_CUSTOMER_ID, + expect.anything(), + ); + }); + + it("lists the customer's entitlements", async () => { + const { auth, headers, user, context } = await setup(); + await context.internalAdapter.updateUser(user.id, { + chargebeeCustomerId: USER_CUSTOMER_ID, + }); + + const entitlements = await auth.api.getEntitlements({ headers }); + + expect(entitlements.map((entitlement) => entitlement.featureId)).toEqual([ + "sso", + "seats", + ]); + }); + + it("evaluates the active organization's customer", async () => { + const { auth, headers, context, customerRequest } = await setup({ + organization: { enabled: true }, + }); + const org = await auth.api.createOrganization({ + headers, + body: { name: "Acme", slug: "acme" }, + }); + await context.adapter.update({ + model: "organization", + where: [{ field: "id", value: org?.id ?? "" }], + update: { chargebeeCustomerId: ORG_CUSTOMER_ID }, + }); + + const result = await auth.api.hasAccess({ + headers, + body: { featureId: "sso", customerType: "organization" }, + }); + + expect(result).toEqual({ hasAccess: true }); + expect(customerRequest).toHaveBeenCalledWith( + ORG_CUSTOMER_ID, + expect.anything(), + ); + }); + + it("denies access without a Chargebee customer", async () => { + const { auth, headers, customerRequest } = await setup(); + + const result = await auth.api.hasAccess({ + headers, + body: { featureId: "sso" }, + }); + const entitlements = await auth.api.getEntitlements({ headers }); + + expect(result).toEqual({ hasAccess: false }); + expect(entitlements).toEqual([]); + expect(customerRequest).not.toHaveBeenCalled(); + }); +}); + +describe("entitlements - subscription target", () => { + it("evaluates a subscription the reference owns", async () => { + const { auth, headers, user, context, subscriptionRequest } = + await setup(); + await context.adapter.create({ + model: "subscription", + data: { + referenceId: user.id, + chargebeeSubscriptionId: SUBSCRIPTION_ID, + status: "active", + }, + }); + + const result = await auth.api.hasAccess({ + headers, + body: { featureId: "reports", subscriptionId: SUBSCRIPTION_ID }, + }); + + expect(result).toEqual({ hasAccess: true }); + expect(subscriptionRequest).toHaveBeenCalledWith( + SUBSCRIPTION_ID, + expect.anything(), + ); + }); + + it("rejects a subscription of another reference", async () => { + const { auth, headers, context, subscriptionRequest } = await setup(); + await context.adapter.create({ + model: "subscription", + data: { + referenceId: "someone-else", + chargebeeSubscriptionId: SUBSCRIPTION_ID, + status: "active", + }, + }); + + await expect( + auth.api.hasAccess({ + headers, + body: { featureId: "reports", subscriptionId: SUBSCRIPTION_ID }, + }), + ).rejects.toMatchObject({ status: "FORBIDDEN" }); + expect(subscriptionRequest).not.toHaveBeenCalled(); + }); +}); + +describe("entitlements - middleware", () => { + it("uses a boolean feature's own default", async () => { + const { auth, headers } = await setup(); + + const result = await auth.api.probe({ headers, query: {} }); + + expect(result).toEqual({ + premium: true, + reports: false, + entitlements: [], + }); + }); + + it("resolves the target once per request", async () => { + const { auth, headers, user, context } = await setup(); + await context.adapter.create({ + model: "subscription", + data: { + referenceId: user.id, + chargebeeSubscriptionId: SUBSCRIPTION_ID, + status: "active", + }, + }); + const findOne = vi.spyOn(context.adapter, "findOne"); + + const result = await auth.api.probe({ + headers, + query: { subscriptionId: SUBSCRIPTION_ID }, + }); + + const subscriptionLookups = findOne.mock.calls.filter( + ([query]) => query.model === "subscription", + ); + expect(subscriptionLookups).toHaveLength(1); + expect(result.reports).toBe(true); + }); + + it("requires the entitlements option", async () => { + const { auth, headers } = await setup({ entitlements: undefined }); + + await expect( + auth.api.hasAccess({ headers, body: { featureId: "sso" } }), + ).rejects.toMatchObject({ + message: CHARGEBEE_ERROR_CODES.ENTITLEMENTS_NOT_CONFIGURED.message, + }); + }); +}); + +/** Compile-time only: non-boolean features have no access semantics. */ +function _rejectsNonBooleanFeatures(options: ChargebeeOptions) { + createAuthEndpoint( + "/typed", + { method: "GET", use: [entitlementsMiddleware(options)] }, + async (ctx) => { + // @ts-expect-error a number feature is not a switch + await ctx.context.entitlements.hasAccess(new Feature("seats", 0)); + }, + ); +} diff --git a/packages/entitlements/CHANGELOG.md b/packages/entitlements/CHANGELOG.md index f5d7b81..4c59073 100644 --- a/packages/entitlements/CHANGELOG.md +++ b/packages/entitlements/CHANGELOG.md @@ -2,6 +2,38 @@ ## Unreleased +- No OpenFeature vocabulary remains in this package; `@chargebee/openfeature` + maps the results instead. `EntitlementResolution` is now + `EntitlementDetails`: `{ value, status, source?, entitlement?, error? }`. + `status` is `granted`, `disabled`, `pending`, `stale`, or `error`; + `error.code` is `not-found`, `type-mismatch`, `invalid-target`, or + `unavailable`. `reason`, `variant`, `flagMetadata`, `errorCode`, and + `errorMessage` are removed, along with the OpenFeature error codes. +- Both clients and `Feature` share one method pair: `get` returns the value, + `getDetails` the `EntitlementDetails`. `getValue` is removed. +- `ChargebeeEntitlementsWebClient` is now `ChargebeeEntitlements` from + `/web` (options: `ChargebeeEntitlementsOptions`), mirroring the server + client. Callbacks match the server's names: `onStale` is + `onSnapshotExpired`, `onConfigurationChanged(flagsChanged)` is + `onSnapshotRefreshed({ snapshot, changedFeatureIds })`, and + `onError(message)` is `onError(error, { operation })`. +- Added `entitlements.for(target)` on the server client. It returns a + `ScopedEntitlements` with the browser client's shape (`get`, `getDetails`, + `feature`, no target) that shares the parent's cache and store. +- `EntitlementsClient` is now `TargetedEntitlementsClient` (server, target + required) or `ScopedEntitlementsClient` (browser client or + `entitlements.for(target)`, no target). `Feature` takes the client type as + a second type parameter, so a server client's `feature(...)` requires + `get(target)` and a scoped client's requires `get()`. A standalone feature + still accepts either; a missing target on the server returns + `invalid-target`. +- The relay stamps `expiresAt` as now plus `relayTtlMs`, even when the server + serves an expired stored snapshot. Browsers no longer refetch on every + evaluation during a Chargebee outage. +- The web client discards relay responses started before `reset()` or + `close()`, and reports a still-expired snapshot through + `onSnapshotExpired` once. +- The package no longer ships `dist/src`, `dist/test`, or `.tsbuildinfo`. - A feature is declared with an ID and a mandatory, concrete default value: `new Feature("licensed-seats", 0)` (or `entitlements.feature(...)`, which binds it to a client). `feature.get(target)` returns the typed value diff --git a/packages/entitlements/README.md b/packages/entitlements/README.md index e95055b..3bb79ac 100644 --- a/packages/entitlements/README.md +++ b/packages/entitlements/README.md @@ -1,85 +1,14 @@ # 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. +Package `@chargebee/entitlements` makes it easy to gate app features by evaluating them against Chargebee's entitlements. It fetches a customer's or subscription's entitlements once, caches them in Redis or in memory, keeps a durable snapshot in your database, and refreshes it in the background. -## Install +You can use it on the server or on the browser with minimal configuration. We have an [adapter](./src/nextjs.ts) for Next.js apps as well. ```sh pnpm add @chargebee/entitlements chargebee ``` -## 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. +## Quick start ```ts import Chargebee from "chargebee"; @@ -93,193 +22,139 @@ const chargebee = new Chargebee({ export const entitlements = new ChargebeeEntitlements({ chargebeeClient: chargebee, }); + +const seats = entitlements.feature("licensed-seats", 0); // Feature +const count = await seats.get({ customerId: user.chargebeeCustomerId }); ``` -A standalone `new Feature(...)` needs a client to evaluate against. Register -one once during start-up: +## Features + +A feature is an ID and a default value. Chargebee stores every value as a string, and the default's type decides how it is read: + +| Default value | Chargebee value | Resolved as | +| --- | --- | --- | +| `boolean` | Switch (`true`, `false`, `available`, or a bare grant) | Boolean | +| `string` | Any | The raw string | +| `number` | Quantity or range; `unlimited` | Number; `Number.POSITIVE_INFINITY` | +| object | Any | The full sanitized entitlement | + +Features can be bound to a client, as show above, or declared once and shared between server and browser code. Standalone features use the client registered with `setDefaultEntitlements`: ```ts -import { setDefaultEntitlements } from "@chargebee/entitlements"; -import { entitlements } from "@/lib/entitlements"; +// features.ts +import { Feature, setDefaultEntitlements } from "@chargebee/entitlements"; +export const seats = new Feature("licensed-seats", 0); +export const reports = new Feature("advanced-reports", false); + +// at start-up setDefaultEntitlements(entitlements); ``` -To avoid the global, create features from the client instead — the returned -feature is bound to it: +`get` returns the value. `getDetails` also says why: ```ts -const seats = entitlements.feature("licensed-seats", 0); +const { value, status, source, entitlement, error } = await seats.getDetails(target); ``` -For call sites that want the full resolution rather than a declared feature, -the client evaluates a feature ID directly: +| `status` | Meaning | `value` | +| --- | --- | --- | +| `granted` | Enabled and read as the declared type | Chargebee's value | +| `disabled` | Disabled or expired | Default | +| `pending` | No snapshot loaded yet | Default | +| `stale` | Browser snapshot expired and refreshing | Default | +| `error` | `error.code` is `not-found`, `type-mismatch`, `invalid-target`, or `unavailable` | Default | -```ts -const resolution = await entitlements.getValue("licensed-seats", 0, { - customerId: session.user.chargebeeCustomerId, -}); -``` +`source` is `api`, `cache`, `store`, or `relay`, and `entitlement` is the Chargebee entitlement behind the value. -## Entitlement mapping +## Targets -The default value's type decides how a stored value is read: +A target is `{ customerId }`, whose entitlements are consolidated across the customer's subscriptions, or `{ subscriptionId }`. Passing both or neither resolves to `invalid-target`, so the package never guesses which one you meant. Other properties are ignored, so a request context can be passed straight through. -| 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 | +The server client evaluates a feature ID directly, or binds a target once per request with `for`: -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`. +```ts +await entitlements.get("licensed-seats", 0, { customerId }); -## Snapshot resolution +const scoped = entitlements.for({ customerId }); +await scoped.get("licensed-seats", 0); +await scoped.feature("advanced-reports", false).get(); +``` -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 bound feature's `get` requires a target on the server client and takes none on a scoped or browser client. Getting this wrong fails to compile. -A snapshot is resolved in three steps: +## Caching and storage -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. +The client loads the complete snapshot for a target, so evaluating several features makes one Chargebee call. It looks for the snapshot in this order: -Both slots take the same `EntitlementsStorage` interface, so any backend can -fill either role. With neither configured, every miss goes to Chargebee. +1. `cache`: fast storage that absorbs the many checks one request makes. +2. `durableStore`: the source of truth, such as a PostgreSQL table. +3. Chargebee, only when the store is empty. The result is written back to both. -```sh -pnpm add ioredis -``` +Both slots take the same `EntitlementsStorage` interface (`get`, `set`, `delete`). With neither configured, every miss goes to Chargebee. ```ts -import { createRedisEntitlementsCache } from "@chargebee/entitlements/cache"; +import { + createMemoryEntitlementsCache, + createRedisEntitlementsCache, +} from "@chargebee/entitlements/cache"; import Redis from "ioredis"; -const redis = new Redis(process.env.REDIS_URL); -const cache = createRedisEntitlementsCache(redis, { ttlMs: 60_000 }); +// Redis when available (pnpm add ioredis), otherwise in-process memory +const cache = process.env.REDIS_URL + ? createRedisEntitlementsCache(new Redis(process.env.REDIS_URL)) + : createMemoryEntitlementsCache({ maxEntries: 500 }); export const entitlements = new ChargebeeEntitlements({ chargebeeClient: chargebee, cache, durableStore: postgresSnapshotStore, snapshotTtlMs: 24 * 60 * 60_000, - 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: +The Redis cache takes an [ioredis](https://github.com/redis/ioredis) client, a `Cluster`, or a custom implementation that satisfies the `RedisEntitlementsCacheClient` interface. -```ts -import type { EntitlementsStorage } from "@chargebee/entitlements/cache"; -import { - parseSerializedEntitlementsSnapshot, - serializeEntitlementsSnapshot, -} from "@chargebee/entitlements/cache"; +The memory cache is a simple in-process LRU cache which holds `maxEntries` (500 by default) snapshots by default before eviction. Each process keeps its own copy, and a webhook refresh only clears the copy in the process that handled it. Due to inconsistencies this may cause, it's strongly suggested that production deployments use the Redis cache instead. -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); - }, -}; -``` +Both caches expire entries after `ttlMs` (60 seconds by default), and the client's `cacheTtlMs` overrides that per write. `snapshotTtlMs` (5 minutes by default) decides when a snapshot is refreshed from Chargebee. Don't delete store rows at `expiresAt`: the client serves an expired snapshot while it refreshes in the background, which keeps entitlements available during a +Chargebee outage. -Cache 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: +Cache and store read failures fall through to the next step and are reported through `onError`. Concurrent refreshes for one target are deduplicated, and a failed refresh waits `advanced.refreshBackoffMs` (10 seconds) before retrying. + +## Webhooks ```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`. +`refreshSnapshot` drops the cached copy before calling Chargebee, so reads fall through to the store until the fresh snapshot lands. It also replaces any refresh a request started earlier. `deleteSnapshot` clears the cache and the store. To save a snapshot you built yourself, for example from a webhook payload, pass `createEntitlementsSnapshot(...)` to `writeSnapshot`. -### Keeping Chargebee off the request path +## Background refresh -`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"`: +With `refreshOnMiss: "background"`, requests never wait on Chargebee. When no snapshot exists yet, evaluations return the default with status `pending` while the client fetches one, and `onSnapshotRefreshed` fires when it lands: ```ts -const entitlements = new ChargebeeEntitlements({ +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"); - }, + onError: (error, { operation, target }) => logger.error({ error, operation, target }), }); ``` -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. +Pending evaluations return the default values, so set them to your lowest paid tier. On platforms that freeze the process after the response, refresh from a webhook worker or a scheduled job instead. -## Browser client and relay +## Browser -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: +The Chargebee API key is secret, so the browser loads a sanitized snapshot from a relay route in your app: ```ts // app/api/entitlements/route.ts import { createEntitlementsRelayHandler } from "@chargebee/entitlements/nextjs"; -import { entitlements } from "@/lib/entitlements"; -import { getSession } from "@/lib/auth"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -288,75 +163,34 @@ export const GET = createEntitlementsRelayHandler({ entitlements, resolveContext: async (request) => { const session = await getSession(request); - if (!session?.user.chargebeeCustomerId) return null; - - return { customerId: session.user.chargebeeCustomerId }; + return session?.user.chargebeeCustomerId + ? { customerId: session.user.chargebeeCustomerId } + : null; // 401 }, }); ``` -`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`. +`resolveContext` must take the billing identity from the authenticated session. The relay rejects `customerId` and `subscriptionId` query parameters, responds with `private, no-store`, and never returns IDs or credentials. Outside Next.js, use the request-generic `createEntitlementsRelayHandler` from `/server`. -Because the relay snapshot is already scoped to the authenticated session, -features evaluated in the browser take no target at all: +The browser client has the server client's name and methods, without the target, and evaluates synchronously: ```ts -import { ChargebeeEntitlementsWebClient } from "@chargebee/entitlements/web"; -import { setDefaultEntitlements } from "@chargebee/entitlements"; +import { ChargebeeEntitlements } from "@chargebee/entitlements/web"; -const webClient = new ChargebeeEntitlementsWebClient({ - relayUrl: "/api/entitlements", -}); -await webClient.initialize(); -setDefaultEntitlements(webClient); +const entitlements = new ChargebeeEntitlements({ relayUrl: "/api/entitlements" }); +await entitlements.initialize(); +setDefaultEntitlements(entitlements); -// The same declarations used on the server, evaluated against the session -// snapshot already in memory: -const count = await features.seats.get(); +entitlements.get("advanced-reports", false); +await seats.get(); // shared features take no target ``` -Or evaluate a feature ID directly, which the web client does synchronously: +Before the first load, evaluations return `pending`. Expired snapshots return defaults with status `stale` while the client refetches. Observe this with `onSnapshotExpired`, `onSnapshotRefreshed` (which lists `changedFeatureIds`), and `onError`. Call `reset()` when the signed-in billing subject changes and `close()` on teardown. For subscription-scoped access, pick and authorize the subscription in `resolveContext`, with one relay URL per subscription. -```ts -const resolution = webClient.getValue("advanced-reports", false); -``` +## Notes -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. +- Customer targets use `consolidate_entitlements=true` by default. +- Without a `durableStore`, an expired snapshot is refetched before the evaluation resolves, as long as the cache `ttlMs` is shorter than `snapshotTtlMs`. +- Node.js is the supported Next.js runtime. +- Call `entitlements.close()` when a long-lived process shuts down. +- For OpenFeature SDKs, use [`@chargebee/openfeature`](https://github.com/chargebee/js-framework-adapters/blob/main/packages/openfeature/README.md). It shares this package's caching and only maps `status` and `error.code` onto OpenFeature reasons and error codes. diff --git a/packages/entitlements/package.json b/packages/entitlements/package.json index 3011734..ff71e4e 100644 --- a/packages/entitlements/package.json +++ b/packages/entitlements/package.json @@ -1,7 +1,7 @@ { "name": "@chargebee/entitlements", "author": "DX Chargebee", - "version": "0.1.0-alpha.2", + "version": "0.1.0-alpha.3", "type": "module", "description": "Framework-agnostic Chargebee entitlements resolution", "license": "MIT", @@ -71,6 +71,9 @@ }, "files": [ "dist", + "!dist/src", + "!dist/test", + "!dist/**/*.tsbuildinfo", "README.md", "LICENSE.md", "CHANGELOG.md" diff --git a/packages/entitlements/src/index.ts b/packages/entitlements/src/index.ts index 4093259..c558988 100644 --- a/packages/entitlements/src/index.ts +++ b/packages/entitlements/src/index.ts @@ -1,12 +1,17 @@ export type { ChargebeeEntitlement, ChargebeeTarget, + EntitlementDetails, + EntitlementError, EntitlementErrorCode, - EntitlementResolution, + EntitlementStatus, Logger, + SnapshotSource, } from "./shared"; export { type EntitlementsClient, Feature, + type ScopedEntitlementsClient, setDefaultEntitlements, + type TargetedEntitlementsClient, } from "./shared"; diff --git a/packages/entitlements/src/server/entitlements.ts b/packages/entitlements/src/server/entitlements.ts index 7f37ecb..5d3a774 100644 --- a/packages/entitlements/src/server/entitlements.ts +++ b/packages/entitlements/src/server/entitlements.ts @@ -4,18 +4,20 @@ import { type ChargebeeEntitlementsSnapshot, type ChargebeeTarget, createEntitlementsSnapshot, - type EntitlementResolution, - errorResolution, + type EntitlementDetails, + errorDetails, Feature, isSnapshotExpired, type Logger, resolveEntitlement, type SnapshotSource, + type TargetedEntitlementsClient, } from "../shared"; import { type ChargebeeEntitlementsClient, ChargebeeEntitlementsLoader, } from "./loader"; +import { ScopedEntitlements } from "./scoped"; type SnapshotOperation = | "cache-read" @@ -88,10 +90,9 @@ export class SnapshotPendingError extends Error { } /** - * 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`. + * Framework-agnostic Chargebee entitlements client for servers. Resolves a + * snapshot from a shared cache, a durable store, or the Chargebee API, and + * evaluates feature IDs against it. */ export class ChargebeeEntitlements { private readonly consolidateCustomerEntitlements: boolean; @@ -155,11 +156,20 @@ export class ChargebeeEntitlements { * Resolves a feature into whatever shape `defaultValue` declares, and falls * back to that default when Chargebee has no usable value. */ - getValue( + async get( featureId: string, defaultValue: T, target: ChargebeeTarget, - ): Promise> { + ): Promise { + return (await this.getDetails(featureId, defaultValue, target)).value; + } + + /** Like {@link get}, but returns the status and entitlement behind the value. */ + getDetails( + featureId: string, + defaultValue: T, + target: ChargebeeTarget, + ): Promise> { return this.evaluate(defaultValue, target, (result) => resolveEntitlement( result.snapshot, @@ -170,6 +180,14 @@ export class ChargebeeEntitlements { ); } + /** + * Binds this client to one target, e.g. once per request, so evaluations + * read like the browser client's: `entitlements.for(target).get(id, 0)`. + */ + for(target: ChargebeeTarget): ScopedEntitlements { + return new ScopedEntitlements(this, target); + } + /** * Declares a feature bound to this client, so its value is fetched with a * single concise call: @@ -180,8 +198,15 @@ export class ChargebeeEntitlements { * const count = await seats.get({ customerId }); * ``` */ - feature(featureId: string, defaultValue: T): Feature { - return new Feature(featureId, defaultValue, this); + feature( + featureId: string, + defaultValue: T, + ): Feature { + return new Feature( + featureId, + defaultValue, + this, + ); } /** @@ -270,6 +295,11 @@ export class ChargebeeEntitlements { await this.durableStore?.delete(key); } + /** + * The server already decided this snapshot is servable (an expired store + * copy outlives Chargebee outages), so the browser trusts it for `ttlMs` + * instead of inheriting a past `expiresAt` and refetching on every check. + */ async getRelaySnapshot( target: ChargebeeTarget, ttlMs = 60_000, @@ -277,32 +307,26 @@ export class ChargebeeEntitlements { const { snapshot } = await this.getSnapshot(target); return { ...snapshot, - expiresAt: new Date( - Math.min(Date.parse(snapshot.expiresAt), Date.now() + ttlMs), - ).toISOString(), + expiresAt: new Date(Date.now() + ttlMs).toISOString(), }; } private async evaluate( defaultValue: T, target: ChargebeeTarget, - resolve: (result: EntitlementsSnapshotResult) => EntitlementResolution, - ): Promise> { + resolve: (result: EntitlementsSnapshotResult) => EntitlementDetails, + ): Promise> { try { return resolve(await this.getSnapshot(target)); } catch (error) { if (error instanceof SnapshotPendingError) { - return { - value: defaultValue, - reason: "STALE", - flagMetadata: { snapshotPending: true }, - }; + return { value: defaultValue, status: "pending" }; } - return errorResolution( + return errorDetails( defaultValue, error instanceof InvalidEntitlementContextError - ? "INVALID_CONTEXT" - : "GENERAL", + ? "invalid-target" + : "unavailable", error instanceof Error ? error.message : "Unable to evaluate Chargebee entitlement", diff --git a/packages/entitlements/src/server/index.ts b/packages/entitlements/src/server/index.ts index 1df988d..fb742da 100644 --- a/packages/entitlements/src/server/index.ts +++ b/packages/entitlements/src/server/index.ts @@ -10,3 +10,4 @@ export { type CreateEntitlementsRelayHandlerOptions, createEntitlementsRelayHandler, } from "./relay"; +export type { ScopedEntitlements } from "./scoped"; diff --git a/packages/entitlements/src/server/scoped.ts b/packages/entitlements/src/server/scoped.ts new file mode 100644 index 0000000..148ff49 --- /dev/null +++ b/packages/entitlements/src/server/scoped.ts @@ -0,0 +1,52 @@ +import { + type ChargebeeTarget, + type EntitlementDetails, + Feature, + type ScopedEntitlementsClient, + type TargetedEntitlementsClient, +} from "../shared"; + +/** + * A server client bound to one target, so call sites read like the browser + * client's: + * + * ```ts + * const scoped = entitlements.for({ customerId }); + * + * await scoped.get("licensed-seats", 0); + * ``` + * + * Cheap enough to create per request: it holds no state of its own and + * shares the parent's cache, store, and in-flight refreshes. The target is + * validated on each evaluation, like the parent's. + */ +export class ScopedEntitlements implements ScopedEntitlementsClient { + constructor( + private readonly client: TargetedEntitlementsClient, + private readonly target: ChargebeeTarget, + ) {} + + async get(featureId: string, defaultValue: T): Promise { + return (await this.getDetails(featureId, defaultValue)).value; + } + + /** Like {@link get}, but returns the status and entitlement behind the value. */ + getDetails( + featureId: string, + defaultValue: T, + ): Promise> { + return this.client.getDetails(featureId, defaultValue, this.target); + } + + /** Declares a feature bound to this target; its `get` takes no arguments. */ + feature( + featureId: string, + defaultValue: T, + ): Feature { + return new Feature( + featureId, + defaultValue, + this, + ); + } +} diff --git a/packages/entitlements/src/shared/evaluation.ts b/packages/entitlements/src/shared/evaluation.ts index 8fe9b81..b51e1f3 100644 --- a/packages/entitlements/src/shared/evaluation.ts +++ b/packages/entitlements/src/shared/evaluation.ts @@ -1,20 +1,21 @@ import type { ChargebeeEntitlement, ChargebeeEntitlementsSnapshot, + EntitlementDetails, + EntitlementError, 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"; +type ParsedValue = { value: unknown } | { error: EntitlementError }; + const BOOLEAN_VALUES = new Set(["true", "false", "available"]); +const TRUE_VALUES = new Set(["true", "available"]); +const UNLIMITED = "unlimited"; +const MS_PER_SECOND = 1000; /** * The shape to parse an entitlement into. Chargebee stores every value as a @@ -30,147 +31,82 @@ function kindOf(defaultValue: unknown): ValueKind { : "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( +export function errorDetails( value: T, - errorCode: EntitlementErrorCode, - errorMessage: string, -): EntitlementResolution { - return { value, reason: "ERROR", errorCode, errorMessage }; + code: EntitlementErrorCode, + message: string, +): EntitlementDetails { + return { value, status: "error", error: { code, message } }; } -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, - }; +/** Disabled and expired (`expiresAt` is in Unix seconds) entitlements grant nothing. */ +function isActive(entitlement: ChargebeeEntitlement): boolean { + if (!entitlement.isEnabled) { + return false; } - return { entitlement, metadata }; + return ( + entitlement.expiresAt === undefined || + entitlement.expiresAt * MS_PER_SECOND > Date.now() + ); } -const reasonFor = (source: SnapshotSource) => - source === "api" ? "TARGETING_MATCH" : "CACHED"; - -interface ParsedEntitlementValue { - value: unknown; - variant?: string; - extraMetadata?: Record; - error?: { code: EntitlementErrorCode; message: string }; -} +const mismatch = (featureId: string, kind: ValueKind): ParsedValue => ({ + error: { + code: "type-mismatch", + message: `Chargebee feature ${featureId} is not a ${kind} entitlement`, + }, +}); -function parseEntitlementValue( +/** + * Reads an entitlement's string value as `kind`, e.g. for `kind` `number`: + * `"25"` → `25`, `"unlimited"` → `Infinity`, `"priority"` → type mismatch. + */ +function parseValue( entitlement: ChargebeeEntitlement, kind: ValueKind, featureId: string, -): ParsedEntitlementValue { +): ParsedValue { switch (kind) { case "boolean": { const normalized = entitlement.value?.trim().toLowerCase(); - if ( - !normalized && - (entitlement.featureType === undefined || - entitlement.featureType === "switch") - ) { - return { value: true, variant: "enabled" }; + + // A switch granted without a value is simply on. + const bareSwitch = + entitlement.featureType === undefined || + entitlement.featureType === "switch"; + if (!normalized && bareSwitch) { + return { value: true }; } + if (!BOOLEAN_VALUES.has(normalized ?? "")) { - return { - value: undefined, - error: { - code: "TYPE_MISMATCH", - message: `Chargebee feature ${featureId} is not a boolean entitlement`, - }, - }; + return mismatch(featureId, kind); } - const value = normalized === "true" || normalized === "available"; - return { value, variant: value ? "enabled" : "disabled" }; + + return { value: TRUE_VALUES.has(normalized ?? "") }; } case "string": { - const value = entitlement.value; - if (value === undefined) { - return { - value: undefined, - error: { - code: "PARSE_ERROR", - message: `Chargebee feature ${featureId} has no value`, - }, - }; + if (entitlement.value === undefined) { + return mismatch(featureId, kind); } - return { value, variant: value }; + + return { value: entitlement.value }; } case "number": { const rawValue = entitlement.value?.trim(); - if (rawValue?.toLowerCase() === "unlimited") { - return { - value: Number.POSITIVE_INFINITY, - variant: "unlimited", - extraMetadata: { unlimited: true }, - }; + if (rawValue?.toLowerCase() === UNLIMITED) { + return { value: Number.POSITIVE_INFINITY }; } + 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 mismatch(featureId, kind); } - return { value, variant: rawValue }; + + return { value }; } case "object": - return { - value: entitlement, - variant: entitlement.value ?? "enabled", - }; + return { value: entitlement }; } } @@ -184,29 +120,33 @@ export function resolveEntitlement( 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, - ); +): EntitlementDetails { + const entitlement = snapshot.entitlements[featureId]; + if (!entitlement) { + return { + ...errorDetails( + defaultValue, + "not-found", + `Chargebee feature ${featureId} was not found`, + ), + source, + }; + } + + if (!isActive(entitlement)) { + return { value: defaultValue, status: "disabled", source, entitlement }; + } + + const parsed = parseValue(entitlement, kindOf(defaultValue), featureId); + if ("error" in parsed) { + return { + value: defaultValue, + status: "error", + source, + entitlement, + error: parsed.error, + }; } - return { - value: parsed.value as T, - variant: parsed.variant, - reason: reasonFor(source), - flagMetadata: parsed.extraMetadata - ? { ...found.metadata, ...parsed.extraMetadata } - : found.metadata, - }; + return { value: parsed.value as T, status: "granted", source, entitlement }; } diff --git a/packages/entitlements/src/shared/feature.ts b/packages/entitlements/src/shared/feature.ts index 28f18b2..0e3a225 100644 --- a/packages/entitlements/src/shared/feature.ts +++ b/packages/entitlements/src/shared/feature.ts @@ -1,16 +1,54 @@ -import type { ChargebeeTarget, EntitlementResolution } from "./types"; +import type { ChargebeeTarget, EntitlementDetails } from "./types"; /** - * The one method a {@link Feature} needs from a client. Both - * `ChargebeeEntitlements` (server) and `ChargebeeEntitlementsWebClient` (web) - * satisfy it directly. + * A server client (`ChargebeeEntitlements` from `/server`): every evaluation + * names a target. */ -export interface EntitlementsClient { - getValue( +export interface TargetedEntitlementsClient { + getDetails( + featureId: string, + defaultValue: T, + target: ChargebeeTarget, + ): Promise>; +} + +/** + * A client already bound to one target, so evaluations take none: the + * browser client (its relay snapshot belongs to the session), or a server + * client scoped with `entitlements.for(target)`. + */ +export interface ScopedEntitlementsClient { + getDetails( + featureId: string, + defaultValue: T, + ): EntitlementDetails | Promise>; +} + +/** Any client a {@link Feature} can evaluate against. */ +export type EntitlementsClient = + | TargetedEntitlementsClient + | ScopedEntitlementsClient; + +/** + * `get` arguments for a client type: + * + * - server client → `get(target)` + * - scoped client (browser, `entitlements.for(target)`) → `get()` + * - unknown (standalone feature, default client) → either + */ +type TargetArgs = + C extends ScopedEntitlementsClient ? [] : [target: ChargebeeTarget]; + +/** + * How a {@link Feature} calls whichever client it resolves to. A web client + * ignores the extra `target` argument. + */ +interface AnyEntitlementsClient { + getDetails( featureId: string, defaultValue: T, target?: ChargebeeTarget, - ): Promise> | EntitlementResolution; + ): Promise> | EntitlementDetails; } let defaultClient: EntitlementsClient | undefined; @@ -42,13 +80,14 @@ export function setDefaultEntitlements( * * 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. + * `entitlements.feature(...)`, to bind one instead. A bound feature's `get` + * requires a target on a server client and takes none on a scoped one. */ -export class Feature { +export class Feature { constructor( readonly featureId: string, readonly defaultValue: T, - private readonly client?: EntitlementsClient, + private readonly client?: C, ) { if (!featureId) throw new Error("featureId is required"); } @@ -56,18 +95,18 @@ export class Feature { /** * 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 + * unavailable. On the browser web client there is no target, because the * relay snapshot is already scoped to the session. */ - async get(target?: ChargebeeTarget): Promise { - return (await this.getDetails(target)).value; + async get(...args: TargetArgs): Promise { + return (await this.getDetails(...args)).value; } - /** Like {@link get}, but returns the full resolution, not just the value. */ - async getDetails( - target?: ChargebeeTarget, - ): Promise> { - const client = this.client ?? defaultClient; + /** Like {@link get}, but returns the status and entitlement behind the value. */ + async getDetails(...args: TargetArgs): Promise> { + const [target] = args; + const client: AnyEntitlementsClient | undefined = + this.client ?? defaultClient; if (!client) { throw new Error( `No Chargebee entitlements client is configured for feature "${this.featureId}". ` + @@ -76,6 +115,6 @@ export class Feature { ); } - return client.getValue(this.featureId, this.defaultValue, target); + return client.getDetails(this.featureId, this.defaultValue, target); } } diff --git a/packages/entitlements/src/shared/index.ts b/packages/entitlements/src/shared/index.ts index e75eef6..2faa645 100644 --- a/packages/entitlements/src/shared/index.ts +++ b/packages/entitlements/src/shared/index.ts @@ -1,8 +1,10 @@ -export { errorResolution, resolveEntitlement } from "./evaluation"; +export { errorDetails, resolveEntitlement } from "./evaluation"; export { type EntitlementsClient, Feature, + type ScopedEntitlementsClient, setDefaultEntitlements, + type TargetedEntitlementsClient, } from "./feature"; export { createEntitlementsSnapshot, @@ -16,8 +18,10 @@ export type { ChargebeeEntitlement, ChargebeeEntitlementsSnapshot, ChargebeeTarget, + EntitlementDetails, + EntitlementError, EntitlementErrorCode, - EntitlementResolution, + EntitlementStatus, Logger, SnapshotSource, } from "./types"; diff --git a/packages/entitlements/src/shared/target.ts b/packages/entitlements/src/shared/target.ts index 10ad54e..640aab5 100644 --- a/packages/entitlements/src/shared/target.ts +++ b/packages/entitlements/src/shared/target.ts @@ -11,9 +11,11 @@ const nonEmpty = (value: unknown): string | undefined => * 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); +export function assertTarget( + target: ChargebeeTarget | undefined, +): ChargebeeTarget { + const customerId = nonEmpty(target?.customerId); + const subscriptionId = nonEmpty(target?.subscriptionId); if (customerId && subscriptionId) { throw new Error( diff --git a/packages/entitlements/src/shared/types.ts b/packages/entitlements/src/shared/types.ts index b7e850a..0fe99e9 100644 --- a/packages/entitlements/src/shared/types.ts +++ b/packages/entitlements/src/shared/types.ts @@ -8,10 +8,7 @@ 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. - */ +/** A minimal, framework-agnostic logger. Structurally compatible with `console`. */ export interface Logger { error(...args: unknown[]): void; warn(...args: unknown[]): void; @@ -44,21 +41,46 @@ export interface ChargebeeEntitlementsSnapshot { */ export type SnapshotSource = "api" | "cache" | "store" | "relay"; +/** + * Why a feature resolved to its value: + * + * - `granted` — the entitlement is enabled and read as the declared type + * - `disabled` — the entitlement is disabled or expired; default returned + * - `pending` — no snapshot is loaded yet; default returned + * - `stale` — the browser snapshot expired and is refreshing; default returned + * - `error` — see {@link EntitlementDetails.error}; default returned + */ +export type EntitlementStatus = + | "granted" + | "disabled" + | "pending" + | "stale" + | "error"; + +/** + * - `not-found` — the snapshot has no entitlement for the feature + * - `type-mismatch` — the value cannot be read as the default value's type + * - `invalid-target` — the target names neither or both identifiers + * - `unavailable` — the snapshot could not be loaded + */ export type EntitlementErrorCode = - | "PROVIDER_NOT_READY" - | "FLAG_NOT_FOUND" - | "PARSE_ERROR" - | "TYPE_MISMATCH" - | "TARGETING_KEY_MISSING" - | "INVALID_CONTEXT" - | "PROVIDER_FATAL" - | "GENERAL"; + | "not-found" + | "type-mismatch" + | "invalid-target" + | "unavailable"; + +export interface EntitlementError { + code: EntitlementErrorCode; + message: string; +} -export interface EntitlementResolution { +export interface EntitlementDetails { value: T; - variant?: string; - reason?: string; - errorCode?: EntitlementErrorCode; - errorMessage?: string; - flagMetadata?: Record; + status: EntitlementStatus; + /** Where the snapshot came from, when one was read. */ + source?: SnapshotSource; + /** The Chargebee entitlement behind the value, when the feature exists. */ + entitlement?: ChargebeeEntitlement; + /** Set when `status` is `error`. */ + error?: EntitlementError; } diff --git a/packages/entitlements/src/web/client.ts b/packages/entitlements/src/web/client.ts index 09029ad..e29eef7 100644 --- a/packages/entitlements/src/web/client.ts +++ b/packages/entitlements/src/web/client.ts @@ -1,26 +1,42 @@ import { type ChargebeeEntitlementsSnapshot, - type EntitlementResolution, - errorResolution, + type EntitlementDetails, Feature, isSnapshotExpired, parseEntitlementsSnapshot, resolveEntitlement, + type ScopedEntitlementsClient, } from "../shared"; -export interface ChargebeeEntitlementsWebClientOptions { +export interface SnapshotErrorInfo { + operation: "refresh"; +} + +export interface SnapshotRefreshedEvent { + snapshot: ChargebeeEntitlementsSnapshot; + /** Features whose entitlement differs from the previous snapshot. */ + changedFeatureIds: string[]; +} + +export interface ChargebeeEntitlementsOptions { relayUrl: string | URL; fetchImplementation?: typeof fetch; credentials?: RequestCredentials; requestHeaders?: HeadersInit; /** The current snapshot has expired and a refresh has started. */ - onStale?: () => void; - /** A refresh completed with entitlements that differ from the previous snapshot. */ - onConfigurationChanged?: (flagsChanged: string[]) => void; - onError?: (message: string) => void; + onSnapshotExpired?: () => void; + /** A refresh (not the initial load) completed. */ + onSnapshotRefreshed?: (event: SnapshotRefreshedEvent) => void; + onError?: (error: unknown, info: SnapshotErrorInfo) => void; } -const changedFlags = ( +/** + * `initial` loads come from `initialize` and report nothing; `refresh` loads + * report through `onSnapshotRefreshed` and `onError`. + */ +type LoadKind = "initial" | "refresh"; + +const changedFeatures = ( before: ChargebeeEntitlementsSnapshot | undefined, after: ChargebeeEntitlementsSnapshot, ) => @@ -30,33 +46,34 @@ const changedFlags = ( ...Object.keys(after.entitlements), ]), ].filter( - (flag) => - JSON.stringify(before?.entitlements[flag]) !== - JSON.stringify(after.entitlements[flag]), + (featureId) => + JSON.stringify(before?.entitlements[featureId]) !== + JSON.stringify(after.entitlements[featureId]), ); /** * Framework-agnostic Chargebee entitlements client for browsers. Fetches a * sanitized snapshot from an authenticated relay endpoint (see * `@chargebee/entitlements/server` or `@chargebee/entitlements/nextjs`) and - * evaluates feature IDs against it synchronously. Use this directly, or wrap - * it with an adapter such as `@chargebee/openfeature`'s - * `ChargebeeEntitlementsWebProvider`. + * evaluates feature IDs against it synchronously. */ -export class ChargebeeEntitlementsWebClient { +export class ChargebeeEntitlements { 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 readonly onSnapshotExpired?: () => void; + private readonly onSnapshotRefreshed?: ( + event: SnapshotRefreshedEvent, + ) => void; + private readonly onError?: (error: unknown, info: SnapshotErrorInfo) => void; private snapshot?: ChargebeeEntitlementsSnapshot; - private staleEventEmitted = false; + private expiryReported = false; private closed = false; private refreshInProgress = false; + private loads = new AbortController(); - constructor(options: ChargebeeEntitlementsWebClientOptions) { + constructor(options: ChargebeeEntitlementsOptions) { if (!options?.relayUrl) throw new Error("relayUrl is required"); this.relayUrl = options.relayUrl.toString(); this.fetchImplementation = options.fetchImplementation ?? globalThis.fetch; @@ -65,33 +82,35 @@ export class ChargebeeEntitlementsWebClient { } this.credentials = options.credentials ?? "same-origin"; this.requestHeaders = options.requestHeaders; - this.onStale = options.onStale; - this.onConfigurationChanged = options.onConfigurationChanged; + this.onSnapshotExpired = options.onSnapshotExpired; + this.onSnapshotRefreshed = options.onSnapshotRefreshed; this.onError = options.onError; } async initialize(): Promise { this.closed = false; - await this.loadSnapshot(false); + await this.loadSnapshot("initial"); } /** Clears the current snapshot and reloads it, e.g. after the session's billing subject changes. */ async reset(): Promise { + this.supersedeLoads(); this.snapshot = undefined; - this.staleEventEmitted = false; + this.expiryReported = false; this.refreshInProgress = false; await this.refreshSnapshot(); } async close(): Promise { + this.supersedeLoads(); this.closed = true; this.snapshot = undefined; - this.staleEventEmitted = false; + this.expiryReported = false; this.refreshInProgress = false; } refreshSnapshot(): Promise { - return this.loadSnapshot(true); + return this.loadSnapshot("refresh"); } /** @@ -99,56 +118,73 @@ export class ChargebeeEntitlementsWebClient { * 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; + get(featureId: string, defaultValue: T): T { + return this.getDetails(featureId, defaultValue).value; + } + + /** Like {@link get}, but returns the status and entitlement behind the value. */ + getDetails(featureId: string, defaultValue: T): EntitlementDetails { + if (!this.snapshot) { + return { value: defaultValue, status: "pending" }; + } + + if (isSnapshotExpired(this.snapshot)) { + this.refreshExpired(); + return { value: defaultValue, status: "stale" }; + } + + return resolveEntitlement(this.snapshot, featureId, defaultValue, "relay"); } /** - * Declares a feature bound to this web client, evaluated synchronously - * against the current relay snapshot. + * Declares a feature bound to this client, evaluated synchronously against + * the current relay snapshot. */ - feature(featureId: string, defaultValue: T): Feature { - return new Feature(featureId, defaultValue, this); + 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", - ); + /** Reports the expiry once and starts a single background refresh. */ + private refreshExpired(): void { + if (!this.expiryReported) { + this.onSnapshotExpired?.(); + this.expiryReported = true; } - if (!isSnapshotExpired(this.snapshot)) return this.snapshot; - - if (!this.staleEventEmitted) { - this.onStale?.(); - this.staleEventEmitted = true; + if (this.refreshInProgress || this.closed) { + return; } - if (!this.refreshInProgress && !this.closed) { - this.refreshInProgress = true; - this.refreshSnapshot() - .catch(() => { - // Errors are already handled in loadSnapshot - }) - .finally(() => { - this.refreshInProgress = false; - }); - } + this.refreshInProgress = true; + this.refreshSnapshot() + .catch(() => { + // Reported through onError inside loadSnapshot. + }) + .finally(() => { + this.refreshInProgress = false; + }); + } - return { value: defaultValue, reason: "STALE" }; + /** + * Aborts loads started before a `reset` or `close`, so a late response for + * the previous session cannot overwrite (or resurrect) the snapshot. + */ + private supersedeLoads(): void { + this.loads.abort(); + this.loads = new AbortController(); } - private async loadSnapshot(emitChange: boolean): Promise { + private async loadSnapshot(kind: LoadKind): Promise { if (this.closed) throw new Error("Chargebee web client is closed"); + const { signal } = this.loads; try { const headers = new Headers(this.requestHeaders); if (!headers.has("Accept")) headers.set("Accept", "application/json"); @@ -157,6 +193,7 @@ export class ChargebeeEntitlementsWebClient { cache: "no-store", credentials: this.credentials, headers, + signal, }); if (!response.ok) { throw new Error( @@ -165,19 +202,31 @@ export class ChargebeeEntitlementsWebClient { } const nextSnapshot = parseEntitlementsSnapshot(await response.json()); - const flagsChanged = changedFlags(this.snapshot, nextSnapshot); + if (signal.aborted) { + return; + } + + const changedFeatureIds = changedFeatures(this.snapshot, nextSnapshot); this.snapshot = nextSnapshot; - this.staleEventEmitted = false; - if (emitChange && flagsChanged.length > 0) { - this.onConfigurationChanged?.(flagsChanged); + + // An already-expired snapshot is still stale; report it once. + if (!isSnapshotExpired(nextSnapshot)) { + this.expiryReported = false; + } + + if (kind === "refresh") { + this.onSnapshotRefreshed?.({ + snapshot: nextSnapshot, + changedFeatureIds, + }); } } catch (error) { - if (emitChange) { - this.onError?.( - error instanceof Error - ? error.message - : "Unable to refresh Chargebee entitlements", - ); + if (signal.aborted) { + return; + } + + if (kind === "refresh") { + this.onError?.(error, { operation: "refresh" }); } throw error; } diff --git a/packages/entitlements/src/web/index.ts b/packages/entitlements/src/web/index.ts index 930c2f5..7515ff5 100644 --- a/packages/entitlements/src/web/index.ts +++ b/packages/entitlements/src/web/index.ts @@ -1,4 +1,6 @@ export { - ChargebeeEntitlementsWebClient, - type ChargebeeEntitlementsWebClientOptions, + ChargebeeEntitlements, + type ChargebeeEntitlementsOptions, + type SnapshotErrorInfo, + type SnapshotRefreshedEvent, } from "./client"; diff --git a/packages/entitlements/test/feature.test.ts b/packages/entitlements/test/feature.test.ts index 73a6c43..5e7cb88 100644 --- a/packages/entitlements/test/feature.test.ts +++ b/packages/entitlements/test/feature.test.ts @@ -3,7 +3,7 @@ import { ChargebeeEntitlements } from "../src/server"; import type { ChargebeeEntitlementsClient } from "../src/server/loader"; import type { ChargebeeTarget, - EntitlementResolution, + EntitlementDetails, EntitlementsClient, } from "../src/shared"; import { Feature, setDefaultEntitlements } from "../src/shared"; @@ -104,7 +104,7 @@ describe("Feature", () => { const details = await supportTier.getDetails(target); expectTypeOf(details).toEqualTypeOf< - EntitlementResolution<{ featureId: string; value?: string }> + EntitlementDetails<{ featureId: string; value?: string }> >(); expect(details.value).toMatchObject({ featureId: "support-tier", @@ -112,15 +112,16 @@ describe("Feature", () => { }); }); - it("exposes the full resolution via getDetails", async () => { + it("exposes the status and entitlement via getDetails", async () => { const seats = makeEntitlements().feature("licensed-seats", 0); const details = await seats.getDetails(target); expect(details).toMatchObject({ value: 25, - reason: "TARGETING_MATCH", - flagMetadata: { chargebeeFeatureId: "licensed-seats" }, + status: "granted", + source: "api", + entitlement: { featureId: "licensed-seats", value: "25" }, }); }); @@ -131,16 +132,17 @@ describe("Feature", () => { await expect(missing.get(target)).resolves.toBe(7); await expect(missing.getDetails(target)).resolves.toMatchObject({ value: 7, - errorCode: "FLAG_NOT_FOUND", + status: "error", + error: { code: "not-found" }, }); }); it("prefers a bound client over the default one", async () => { const calls: string[] = []; const stub = (name: string, value: number): EntitlementsClient => ({ - getValue: async () => { + getDetails: async () => { calls.push(name); - return { value: value as never }; + return { value: value as never, status: "granted" as const }; }, }); setDefaultEntitlements(stub("default", 1)); @@ -157,6 +159,28 @@ describe("Feature", () => { await expect(seats.get(target)).resolves.toBe(25); }); + it("rejects a missing target on the server as invalid-target", async () => { + setDefaultEntitlements(makeEntitlements()); + const seats = new Feature("licensed-seats", 0); + + await expect(seats.getDetails()).resolves.toMatchObject({ + value: 0, + status: "error", + error: { + code: "invalid-target", + message: expect.stringMatching(/customerId or subscriptionId/), + }, + }); + }); + + it("requires a target on features bound to a server client", async () => { + const seats = makeEntitlements().feature("licensed-seats", 0); + + // @ts-expect-error A server feature needs a target. + await expect(seats.get()).resolves.toBe(0); + await expect(seats.get(target)).resolves.toBe(25); + }); + it("throws a helpful error when no client is configured", async () => { const seats = new Feature("licensed-seats", 0); @@ -166,21 +190,27 @@ describe("Feature", () => { }); }); -describe("ChargebeeEntitlements.getValue", () => { +describe("ChargebeeEntitlements.get", () => { it("resolves every shape through a single method", async () => { const entitlements = makeEntitlements(); await expect( - entitlements.getValue("licensed-seats", 0, target), - ).resolves.toMatchObject({ value: 25 }); + entitlements.get("licensed-seats", 0, target), + ).resolves.toBe(25); await expect( - entitlements.getValue("support-tier", "basic", target), - ).resolves.toMatchObject({ value: "priority" }); + entitlements.get("support-tier", "basic", target), + ).resolves.toBe("priority"); await expect( - entitlements.getValue("advanced-reports", false, target), - ).resolves.toMatchObject({ value: true }); + entitlements.get("advanced-reports", false, target), + ).resolves.toBe(true); + await expect( + entitlements.get("support-tier", {}, target), + ).resolves.toMatchObject({ featureId: "support-tier" }); + }); + + it("returns the status and entitlement via getDetails", async () => { await expect( - entitlements.getValue("support-tier", {}, target), - ).resolves.toMatchObject({ value: { featureId: "support-tier" } }); + makeEntitlements().getDetails("licensed-seats", 0, target), + ).resolves.toMatchObject({ value: 25, status: "granted" }); }); }); diff --git a/packages/entitlements/test/server.test.ts b/packages/entitlements/test/server.test.ts index a250703..373b8d9 100644 --- a/packages/entitlements/test/server.test.ts +++ b/packages/entitlements/test/server.test.ts @@ -6,6 +6,7 @@ import { import { ChargebeeEntitlements, createEntitlementsRelayHandler, + createEntitlementsSnapshot, } from "../src/server"; import type { ChargebeeEntitlementsClient } from "../src/server/loader"; import type { @@ -99,11 +100,11 @@ describe("ChargebeeEntitlements", () => { cacheTtlMs: 60_000, }); - const first = await entitlements.getValue("sso", false, target); - const second = await entitlements.getValue("seats", 0, target); + const first = await entitlements.getDetails("sso", false, target); + const second = await entitlements.getDetails("seats", 0, target); - expect(first).toMatchObject({ value: true, reason: "TARGETING_MATCH" }); - expect(second).toMatchObject({ value: 10, reason: "CACHED" }); + expect(first).toMatchObject({ value: true, source: "api" }); + expect(second).toMatchObject({ value: 10, source: "cache" }); expect(customerRequest).toHaveBeenCalledTimes(2); expect(customerRequest).toHaveBeenLastCalledWith( "customer-1", @@ -185,28 +186,26 @@ describe("ChargebeeEntitlements", () => { expect(customerRequest).toHaveBeenCalledTimes(2); }); - it("returns INVALID_CONTEXT without calling Chargebee", async () => { + it("returns invalid-target 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), + entitlements.getDetails("sso", false, { userId: "app-user-1" } as never), ).resolves.toMatchObject({ value: false, - errorCode: "INVALID_CONTEXT", + error: { code: "invalid-target" }, }); await expect( - entitlements.getValue("sso", false, { + entitlements.getDetails("sso", false, { customerId: "customer-1", subscriptionId: "subscription-1", } as never), ).resolves.toMatchObject({ value: false, - errorCode: "INVALID_CONTEXT", + error: { code: "invalid-target" }, }); expect(customerRequest).not.toHaveBeenCalled(); }); @@ -240,13 +239,10 @@ describe("ChargebeeEntitlements", () => { }); await expect( - entitlements.getValue("seats", 0, { subscriptionId: "subscription-1" }), + entitlements.getDetails("seats", 0, { subscriptionId: "subscription-1" }), ).resolves.toMatchObject({ value: 50, - flagMetadata: { - chargebeeFeatureType: "quantity", - chargebeeOverridden: true, - }, + entitlement: { featureType: "quantity", isOverridden: true }, }); expect(subscriptionRequest).toHaveBeenCalledWith( "subscription-1", @@ -258,13 +254,11 @@ describe("ChargebeeEntitlements", () => { const { client } = makeClient([ssoPage]); const entitlements = new ChargebeeEntitlements({ chargebeeClient: client }); const context = { - targetingKey: "app-user-1", + userId: "app-user-1", customerId: "customer-1", }; - await expect( - entitlements.getValue("sso", false, context), - ).resolves.toMatchObject({ value: true, reason: "TARGETING_MATCH" }); + await expect(entitlements.get("sso", false, context)).resolves.toBe(true); }); it("reads the cache first, then the store, and hydrates the cache", async () => { @@ -311,11 +305,11 @@ describe("ChargebeeEntitlements", () => { await entitlements.refreshSnapshot(target); await expect( - entitlements.getValue("sso", false, target), + entitlements.getDetails("sso", false, target), ).resolves.toMatchObject({ value: true, - reason: "CACHED", - flagMetadata: { cacheSource: "store" }, + status: "granted", + source: "store", }); expect(onError).toHaveBeenCalledWith(expect.any(Error), { operation: "cache-read", @@ -389,8 +383,8 @@ describe("ChargebeeEntitlements", () => { }); await expect( - entitlements.getValue("sso", false, target), - ).resolves.toMatchObject({ reason: "STALE" }); + entitlements.getDetails("sso", false, target), + ).resolves.toMatchObject({ status: "pending" }); await vi.waitFor(() => expect(customerRequest).toHaveBeenCalledTimes(1)); const explicitRefresh = entitlements.refreshSnapshot(target); @@ -424,11 +418,9 @@ describe("ChargebeeEntitlements", () => { onSnapshotRefreshed, }); - await expect(entitlements.getValue("sso", false, target)).resolves.toEqual({ - value: false, - reason: "STALE", - flagMetadata: { snapshotPending: true }, - }); + await expect( + entitlements.getDetails("sso", false, target), + ).resolves.toEqual({ value: false, status: "pending" }); await vi.waitFor(() => expect(onSnapshotRefreshed).toHaveBeenCalledWith( @@ -436,8 +428,8 @@ describe("ChargebeeEntitlements", () => { ), ); await expect( - entitlements.getValue("sso", false, target), - ).resolves.toMatchObject({ value: true, reason: "CACHED" }); + entitlements.getDetails("sso", false, target), + ).resolves.toMatchObject({ value: true, source: "store" }); expect(customerRequest).toHaveBeenCalledTimes(1); }); @@ -475,14 +467,14 @@ describe("ChargebeeEntitlements", () => { advanced: { refreshBackoffMs: 60_000 }, }); - await entitlements.getValue("sso", false, target); + await entitlements.get("sso", false, target); await vi.waitFor(() => expect(onError).toHaveBeenCalledWith( expect.any(Error), expect.objectContaining({ operation: "refresh" }), ), ); - await entitlements.getValue("sso", false, target); + await entitlements.get("sso", false, target); expect(customerRequest).toHaveBeenCalledTimes(1); }); @@ -505,6 +497,58 @@ describe("ChargebeeEntitlements", () => { }); }); +describe("ChargebeeEntitlements.for", () => { + it("evaluates against the bound target without passing it per call", async () => { + const { client } = makeClient([ssoPage]); + const scoped = new ChargebeeEntitlements({ chargebeeClient: client }).for( + target, + ); + + await expect(scoped.get("sso", false)).resolves.toBe(true); + await expect(scoped.getDetails("sso", false)).resolves.toMatchObject({ + value: true, + status: "granted", + }); + }); + + it("shares the parent's snapshot", async () => { + const { client, customerRequest } = makeClient([ssoPage]); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: client, + cache: createMemoryEntitlementsCache(), + }); + + await entitlements.get("sso", false, target); + await entitlements.for(target).get("sso", false); + + expect(customerRequest).toHaveBeenCalledTimes(1); + }); + + it("binds features that take no target", async () => { + const { client } = makeClient([ssoPage]); + const sso = new ChargebeeEntitlements({ chargebeeClient: client }) + .for(target) + .feature("sso", false); + + await expect(sso.get()).resolves.toBe(true); + // @ts-expect-error A scoped feature takes no target. + await expect(sso.get(target)).resolves.toBe(true); + }); + + it("reports an invalid target on evaluation", async () => { + const { client, customerRequest } = makeClient([ssoPage]); + const scoped = new ChargebeeEntitlements({ chargebeeClient: client }).for( + {} as never, + ); + + await expect(scoped.getDetails("sso", false)).resolves.toMatchObject({ + value: false, + error: { code: "invalid-target" }, + }); + expect(customerRequest).not.toHaveBeenCalled(); + }); +}); + describe("entitlement relay", () => { it("derives identity on the server and rejects spoofed IDs", async () => { const { client } = makeClient([{ list: [] }]); @@ -550,6 +594,24 @@ describe("entitlement relay", () => { expect(response.status).toBe(401); }); + it("lets the browser trust a stale snapshot for the relay TTL", async () => { + const { client } = makeClient([ssoPage]); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: client, + durableStore: makeDurableStore(), + }); + const expired = createEntitlementsSnapshot( + [{ featureId: "sso", value: "true", isEnabled: true }], + 500, + Date.now() - 1_000, + ); + await entitlements.writeSnapshot(target, expired); + + const relayed = await entitlements.getRelaySnapshot(target, 60_000); + + expect(Date.parse(relayed.expiresAt)).toBeGreaterThan(Date.now()); + }); + it("rejects methods other than GET", async () => { const { client } = makeClient([]); const resolveContext = vi.fn(async () => target); diff --git a/packages/entitlements/test/shared.test.ts b/packages/entitlements/test/shared.test.ts index 961c569..c412eea 100644 --- a/packages/entitlements/test/shared.test.ts +++ b/packages/entitlements/test/shared.test.ts @@ -38,15 +38,16 @@ describe("entitlement mapping", () => { resolveEntitlement(snapshot, "switch-on", false, "api"), ).toMatchObject({ value: true, - variant: "enabled", - reason: "TARGETING_MATCH", + status: "granted", + source: "api", + entitlement: { featureId: "switch-on" }, }); expect( resolveEntitlement(snapshot, "switch-off", true, "cache"), - ).toMatchObject({ value: false, reason: "CACHED" }); + ).toMatchObject({ value: false, status: "granted", source: "cache" }); expect( resolveEntitlement(snapshot, "switch-enabled", false, "api"), - ).toMatchObject({ value: true, variant: "enabled" }); + ).toMatchObject({ value: true, status: "granted" }); expect( resolveEntitlement(snapshot, "switch-available", false, "api"), ).toMatchObject({ value: true }); @@ -55,19 +56,19 @@ describe("entitlement mapping", () => { it("maps numeric and unlimited entitlements", () => { expect(resolveEntitlement(snapshot, "seats", 0, "store")).toMatchObject({ value: 25, - reason: "CACHED", + status: "granted", + source: "store", }); expect(resolveEntitlement(snapshot, "storage", 0, "relay")).toMatchObject({ value: Number.POSITIVE_INFINITY, - variant: "unlimited", - flagMetadata: { unlimited: true }, + status: "granted", }); }); it("maps string and object entitlements", () => { expect( resolveEntitlement(snapshot, "support", "basic", "api"), - ).toMatchObject({ value: "priority", variant: "priority" }); + ).toMatchObject({ value: "priority", status: "granted" }); expect( resolveEntitlement>( snapshot, @@ -95,27 +96,28 @@ describe("entitlement mapping", () => { it("fails closed for disabled, expired, missing, and mismatched values", () => { expect(resolveEntitlement(snapshot, "disabled", false, "api")).toMatchObject( - { value: false, reason: "DISABLED" }, + { value: false, status: "disabled" }, ); expect(resolveEntitlement(snapshot, "expired", false, "api")).toMatchObject({ value: false, - reason: "DISABLED", + status: "disabled", }); expect(resolveEntitlement(snapshot, "missing", false, "api")).toMatchObject({ value: false, - errorCode: "FLAG_NOT_FOUND", + status: "error", + error: { code: "not-found" }, }); expect(resolveEntitlement(snapshot, "support", false, "api")).toMatchObject({ value: false, - errorCode: "TYPE_MISMATCH", + error: { code: "type-mismatch" }, }); expect(resolveEntitlement(snapshot, "support", 0, "api")).toMatchObject({ value: 0, - errorCode: "TYPE_MISMATCH", + error: { code: "type-mismatch" }, }); expect( resolveEntitlement(snapshot, "switch-enabled", "basic", "api"), - ).toMatchObject({ value: "basic", errorCode: "PARSE_ERROR" }); + ).toMatchObject({ value: "basic", error: { code: "type-mismatch" } }); }); }); @@ -131,7 +133,7 @@ describe("target validation", () => { it("ignores unrelated properties a caller's context carries", () => { const context = { - targetingKey: "app-user-1", + userId: "app-user-1", customerId: "customer-1", plan: "pro", }; @@ -149,7 +151,7 @@ describe("target validation", () => { }); it("rejects a target with no usable identifier", () => { - expect(() => assertTarget({ targetingKey: "app-user-1" } as never)).toThrow( + expect(() => assertTarget({ userId: "app-user-1" } as never)).toThrow( "requires a non-empty customerId or subscriptionId", ); expect(() => assertTarget({ customerId: "" } as never)).toThrow( diff --git a/packages/entitlements/test/web.test.ts b/packages/entitlements/test/web.test.ts index 36480c2..c8ebc53 100644 --- a/packages/entitlements/test/web.test.ts +++ b/packages/entitlements/test/web.test.ts @@ -3,27 +3,44 @@ import { Feature, setDefaultEntitlements, } from "../src/shared"; -import { ChargebeeEntitlementsWebClient } from "../src/web"; +import { ChargebeeEntitlements } from "../src/web"; afterEach(() => { setDefaultEntitlements(undefined); }); -describe("ChargebeeEntitlementsWebClient", () => { +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +const snapshotWith = (sso: string) => + createEntitlementsSnapshot( + [{ featureId: "sso", value: sso, isEnabled: true }], + 60_000, + ); + +describe("ChargebeeEntitlements (web)", () => { it("loads a relay snapshot and evaluates synchronously", async () => { - const snapshot = createEntitlementsSnapshot( - [{ featureId: "sso", value: "true", isEnabled: true }], - 60_000, + const fetchImplementation = vi.fn(async () => + Response.json(snapshotWith("true")), ); - const fetchImplementation = vi.fn(async () => Response.json(snapshot)); - const client = new ChargebeeEntitlementsWebClient({ + const client = new ChargebeeEntitlements({ relayUrl: "/api/entitlements", fetchImplementation, }); await client.initialize(); - expect(client.getValue("sso", false)).toMatchObject({ value: true }); + expect(client.get("sso", false)).toBe(true); + expect(client.getDetails("sso", false)).toMatchObject({ + value: true, + status: "granted", + source: "relay", + }); expect(fetchImplementation).toHaveBeenCalledWith( "/api/entitlements", expect.objectContaining({ @@ -33,67 +50,132 @@ describe("ChargebeeEntitlementsWebClient", () => { ); }); + it("reports pending before the snapshot loads", () => { + const client = new ChargebeeEntitlements({ + relayUrl: "/api/entitlements", + fetchImplementation: async () => Response.json(snapshotWith("true")), + }); + + expect(client.getDetails("sso", false)).toEqual({ + value: false, + status: "pending", + }); + }); + it("fails closed when the relay snapshot expires", async () => { const snapshot = createEntitlementsSnapshot( [{ featureId: "sso", value: "true", isEnabled: true }], 500, Date.now() - 1_000, ); - const onStale = vi.fn(); - const client = new ChargebeeEntitlementsWebClient({ + const onSnapshotExpired = vi.fn(); + const client = new ChargebeeEntitlements({ relayUrl: "/api/entitlements", fetchImplementation: async () => Response.json(snapshot), - onStale, + onSnapshotExpired, }); await client.initialize(); - expect(client.getValue("sso", false)).toMatchObject({ + expect(client.getDetails("sso", false)).toMatchObject({ value: false, - reason: "STALE", + status: "stale", }); - expect(onStale).toHaveBeenCalledTimes(1); + expect(onSnapshotExpired).toHaveBeenCalledTimes(1); }); - it("refreshes its snapshot on reset", async () => { - const first = createEntitlementsSnapshot( - [{ featureId: "sso", value: "false", isEnabled: true }], - 60_000, - ); - const second = createEntitlementsSnapshot( + it("reports expiry once while the relay keeps serving an expired snapshot", async () => { + const snapshot = createEntitlementsSnapshot( [{ featureId: "sso", value: "true", isEnabled: true }], - 60_000, + 500, + Date.now() - 1_000, ); + const fetchImplementation = vi.fn(async () => Response.json(snapshot)); + const onSnapshotExpired = vi.fn(); + const client = new ChargebeeEntitlements({ + relayUrl: "/api/entitlements", + fetchImplementation, + onSnapshotExpired, + }); + await client.initialize(); + + client.get("sso", false); + await vi.waitFor(() => expect(fetchImplementation).toHaveBeenCalledTimes(2)); + await new Promise((resolve) => setTimeout(resolve, 0)); + client.get("sso", false); + + expect(onSnapshotExpired).toHaveBeenCalledTimes(1); + }); + + it("discards a relay response that lands after close", async () => { + const pending = deferred(); + const client = new ChargebeeEntitlements({ + relayUrl: "/api/entitlements", + fetchImplementation: () => pending.promise, + }); + + const loading = client.initialize(); + await client.close(); + pending.resolve(Response.json(snapshotWith("true"))); + await loading; + + expect(client.getDetails("sso", false)).toMatchObject({ + status: "pending", + }); + }); + + it("keeps the new subject's snapshot when an old response lands after reset", async () => { + const pending = deferred(); + const fetchImplementation = vi + .fn() + .mockResolvedValueOnce(Response.json(snapshotWith("false"))) + .mockReturnValueOnce(pending.promise) + .mockResolvedValueOnce(Response.json(snapshotWith("true"))); + const client = new ChargebeeEntitlements({ + relayUrl: "/api/entitlements", + fetchImplementation, + }); + await client.initialize(); + + const oldRefresh = client.refreshSnapshot(); + await client.reset(); + pending.resolve(Response.json(snapshotWith("false"))); + await oldRefresh; + + expect(client.get("sso", false)).toBe(true); + }); + + it("refreshes its snapshot on reset", async () => { const fetchImplementation = vi .fn() - .mockResolvedValueOnce(Response.json(first)) - .mockResolvedValueOnce(Response.json(second)); - const onConfigurationChanged = vi.fn(); - const client = new ChargebeeEntitlementsWebClient({ + .mockResolvedValueOnce(Response.json(snapshotWith("false"))) + .mockResolvedValueOnce(Response.json(snapshotWith("true"))); + const onSnapshotRefreshed = vi.fn(); + const client = new ChargebeeEntitlements({ relayUrl: "/api/entitlements", fetchImplementation, - onConfigurationChanged, + onSnapshotRefreshed, }); await client.initialize(); - expect(client.getValue("sso", true).value).toBe(false); + expect(client.get("sso", true)).toBe(false); + expect(onSnapshotRefreshed).not.toHaveBeenCalled(); + await client.reset(); - expect(client.getValue("sso", false).value).toBe(true); - expect(onConfigurationChanged).toHaveBeenCalledWith(["sso"]); + expect(client.get("sso", false)).toBe(true); + expect(onSnapshotRefreshed).toHaveBeenCalledWith( + expect.objectContaining({ changedFeatureIds: ["sso"] }), + ); }); it("does not retain the previous subject's snapshot after a failed reset", async () => { - const snapshot = createEntitlementsSnapshot( - [{ featureId: "sso", value: "true", isEnabled: true }], - 60_000, - ); const fetchImplementation = vi .fn() - .mockResolvedValueOnce(Response.json(snapshot)) + .mockResolvedValueOnce(Response.json(snapshotWith("true"))) .mockResolvedValueOnce( Response.json({ error: "Unauthorized" }, { status: 401 }), ); const onError = vi.fn(); - const client = new ChargebeeEntitlementsWebClient({ + const client = new ChargebeeEntitlements({ relayUrl: "/api/entitlements", fetchImplementation, onError, @@ -101,10 +183,13 @@ describe("ChargebeeEntitlementsWebClient", () => { await client.initialize(); await expect(client.reset()).rejects.toThrow("HTTP 401"); - expect(onError).toHaveBeenCalledWith(expect.stringContaining("HTTP 401")); - expect(client.getValue("sso", false)).toMatchObject({ + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("HTTP 401") }), + { operation: "refresh" }, + ); + expect(client.getDetails("sso", false)).toMatchObject({ value: false, - errorCode: "PROVIDER_NOT_READY", + status: "pending", }); }); @@ -116,20 +201,22 @@ describe("ChargebeeEntitlementsWebClient", () => { ], 60_000, ); - const client = new ChargebeeEntitlementsWebClient({ + const client = new ChargebeeEntitlements({ relayUrl: "/api/entitlements", fetchImplementation: async () => Response.json(snapshot), }); await client.initialize(); - // Bound client via webClient.feature() + // Bound client via client.feature() const ssoFeature = client.feature("sso", false); const seatsFeature = client.feature("seats", 0); expect(await ssoFeature.get()).toBe(true); expect(await seatsFeature.get()).toBe(10); + // @ts-expect-error A web feature takes no target. + expect(await seatsFeature.get({ customerId: "customer-1" })).toBe(10); - // Global client via setDefaultEntitlements(webClient) + // Global client via setDefaultEntitlements(client) setDefaultEntitlements(client); const standaloneFeature = new Feature("seats", 0); expect(await standaloneFeature.get()).toBe(10); diff --git a/packages/openfeature/CHANGELOG.md b/packages/openfeature/CHANGELOG.md index f390956..5a68bab 100644 --- a/packages/openfeature/CHANGELOG.md +++ b/packages/openfeature/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- The OpenFeature mapping moved here from `@chargebee/entitlements`, which now + returns SDK-agnostic `EntitlementDetails`. Provider results are unchanged: + `status` and `error.code` map to the same reasons, error codes, variants, + and `flagMetadata` as before. The root re-exports `EntitlementDetails`, + `EntitlementStatus`, and `EntitlementErrorCode` in place of + `EntitlementResolution`. + - **Breaking:** `ChargebeeEntitlementsWebProvider.client` is now + `.entitlements`, matching the server provider. - Extracted all framework/SDK-agnostic logic (snapshot caching, background refresh, Chargebee loading, evaluation, the browser relay) into a new package, [`@chargebee/entitlements`](https://github.com/chargebee/js-framework-adapters/blob/main/packages/entitlements/README.md). diff --git a/packages/openfeature/package.json b/packages/openfeature/package.json index 5e18a26..225e22f 100644 --- a/packages/openfeature/package.json +++ b/packages/openfeature/package.json @@ -1,7 +1,7 @@ { "name": "@chargebee/openfeature", "author": "DX Chargebee", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.2", "type": "module", "description": "OpenFeature provider for Chargebee", "license": "MIT", diff --git a/packages/openfeature/src/index.ts b/packages/openfeature/src/index.ts index 615d8aa..4ff1a1e 100644 --- a/packages/openfeature/src/index.ts +++ b/packages/openfeature/src/index.ts @@ -1,7 +1,8 @@ export type { ChargebeeEntitlement, ChargebeeTarget, + EntitlementDetails, EntitlementErrorCode, - EntitlementResolution, + EntitlementStatus, Logger, } from "@chargebee/entitlements"; diff --git a/packages/openfeature/src/resolution.ts b/packages/openfeature/src/resolution.ts index f44162c..c132711 100644 --- a/packages/openfeature/src/resolution.ts +++ b/packages/openfeature/src/resolution.ts @@ -1,17 +1,158 @@ -import type { EntitlementResolution } from "@chargebee/entitlements"; +import type { + EntitlementDetails, + EntitlementErrorCode, +} 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. + * How a `pending` status (no snapshot loaded yet) surfaces. The server SDK + * evaluates per request, so a pending snapshot is stale data; the web SDK + * models it as a provider that is not ready. + */ +export enum PendingAs { + Stale = "stale", + NotReady = "not-ready", +} + +type FlagMetadata = Record; + +/** Structurally the `ResolutionDetails` both OpenFeature SDKs expect. */ +export interface OpenFeatureResolution { + value: T; + variant?: string; + reason?: string; + errorCode?: TErrorCode; + errorMessage?: string; + flagMetadata?: FlagMetadata; +} + +/** OpenFeature's `StandardResolutionReasons`, shared by both SDKs. */ +const Reason = { + TargetingMatch: "TARGETING_MATCH", + Cached: "CACHED", + Disabled: "DISABLED", + Stale: "STALE", + Error: "ERROR", +} as const; + +/** OpenFeature's `ErrorCode` values, shared by both SDKs. */ +const ERROR_CODES: Record = { + "not-found": "FLAG_NOT_FOUND", + "type-mismatch": "TYPE_MISMATCH", + "invalid-target": "INVALID_CONTEXT", + unavailable: "GENERAL", +}; +const PROVIDER_NOT_READY = "PROVIDER_NOT_READY"; + +/** `granted` values become a variant, e.g. `true` → `enabled`, `Infinity` → `unlimited`. */ +function variantFor(details: EntitlementDetails): string | undefined { + if (details.status === "disabled") { + return "disabled"; + } + + if (details.status !== "granted") { + return undefined; + } + + const { value } = details; + if (typeof value === "boolean") { + return value ? "enabled" : "disabled"; + } + + if (value === Number.POSITIVE_INFINITY) { + return "unlimited"; + } + + if (typeof value === "string" || typeof value === "number") { + return String(value); + } + + return details.entitlement?.value ?? "enabled"; +} + +/** Flattens the Chargebee entitlement into OpenFeature's primitive-only metadata. */ +function metadataFor( + details: EntitlementDetails, +): FlagMetadata | undefined { + const { entitlement, source } = details; + if (!entitlement) { + return undefined; + } + + const optional: Record = { + chargebeeValue: entitlement.value, + chargebeeFeatureType: entitlement.featureType, + chargebeeFeatureUnit: entitlement.featureUnit, + chargebeeOverridden: entitlement.isOverridden, + chargebeeExpiresAt: entitlement.expiresAt, + cacheSource: source, + unlimited: details.value === Number.POSITIVE_INFINITY ? true : undefined, + }; + + return { + chargebeeFeatureId: entitlement.featureId, + chargebeeEnabled: entitlement.isEnabled, + ...(Object.fromEntries( + Object.entries(optional).filter(([, value]) => value !== undefined), + ) as FlagMetadata), + }; +} + +function reasonFor(details: EntitlementDetails): string { + switch (details.status) { + case "granted": + return details.source === "api" ? Reason.TargetingMatch : Reason.Cached; + case "disabled": + return Reason.Disabled; + case "stale": + case "pending": + return Reason.Stale; + case "error": + return Reason.Error; + } +} + +/** + * Maps SDK-agnostic {@link EntitlementDetails} onto OpenFeature's resolution + * shape. The server and web SDKs declare structurally identical but + * nominally distinct `ErrorCode` enums, so the target error code type is a + * generic parameter instead of a hard dependency on either SDK package. */ export function toResolutionDetails( - resolution: EntitlementResolution, -): Omit, "errorCode"> & { errorCode?: TErrorCode } { + details: EntitlementDetails, + pendingAs: PendingAs, +): OpenFeatureResolution { + const { value } = details; + + if (details.status === "pending" && pendingAs === PendingAs.NotReady) { + return { + value, + reason: Reason.Error, + errorCode: PROVIDER_NOT_READY as TErrorCode, + errorMessage: "Chargebee entitlement snapshot is not loaded", + }; + } + + if (details.status === "pending") { + return { + value, + reason: Reason.Stale, + flagMetadata: { snapshotPending: true }, + }; + } + + if (details.error) { + return { + value, + reason: Reason.Error, + errorCode: ERROR_CODES[details.error.code] as TErrorCode, + errorMessage: details.error.message, + }; + } + return { - ...resolution, - errorCode: resolution.errorCode as unknown as TErrorCode | undefined, + value, + variant: variantFor(details), + reason: reasonFor(details), + flagMetadata: metadataFor(details), }; } diff --git a/packages/openfeature/src/server/provider.ts b/packages/openfeature/src/server/provider.ts index a5417df..d93c7fd 100644 --- a/packages/openfeature/src/server/provider.ts +++ b/packages/openfeature/src/server/provider.ts @@ -10,7 +10,7 @@ import type { Provider, ResolutionDetails, } from "@openfeature/server-sdk"; -import { toResolutionDetails } from "../resolution"; +import { PendingAs, toResolutionDetails } from "../resolution"; export type ChargebeeEntitlementsProviderOptions = | ChargebeeEntitlementsOptions @@ -20,7 +20,7 @@ export type ChargebeeEntitlementsProviderOptions = * 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`. + * `invalid-target`, surfaced here as `INVALID_CONTEXT`. */ const targetFrom = (context: EvaluationContext): ChargebeeTarget => ({ @@ -89,7 +89,7 @@ export class ChargebeeEntitlementsProvider implements Provider { /** * One evaluation path for all four flag types: the default value's runtime - * type already tells `getValue` which shape to parse the entitlement into. + * type already tells `getDetails` which shape to parse the entitlement into. */ private async resolve( flagKey: string, @@ -97,11 +97,12 @@ export class ChargebeeEntitlementsProvider implements Provider { context: EvaluationContext, ): Promise> { return toResolutionDetails( - await this.entitlements.getValue( + await this.entitlements.getDetails( flagKey, defaultValue, targetFrom(context), ), + PendingAs.Stale, ); } } diff --git a/packages/openfeature/src/web/provider.ts b/packages/openfeature/src/web/provider.ts index b649e12..42daa9f 100644 --- a/packages/openfeature/src/web/provider.ts +++ b/packages/openfeature/src/web/provider.ts @@ -1,6 +1,6 @@ import { - ChargebeeEntitlementsWebClient, - type ChargebeeEntitlementsWebClientOptions, + ChargebeeEntitlements, + type ChargebeeEntitlementsOptions, } from "@chargebee/entitlements/web"; import { type ErrorCode, @@ -11,58 +11,67 @@ import { ProviderEvents, type ResolutionDetails, } from "@openfeature/web-sdk"; -import { toResolutionDetails } from "../resolution"; +import { PendingAs, toResolutionDetails } from "../resolution"; export type ChargebeeEntitlementsWebProviderOptions = Omit< - ChargebeeEntitlementsWebClientOptions, - "onStale" | "onConfigurationChanged" | "onError" + ChargebeeEntitlementsOptions, + "onSnapshotExpired" | "onSnapshotRefreshed" | "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. + * Adapts `@chargebee/entitlements/web`'s framework-agnostic + * `ChargebeeEntitlements` to the OpenFeature web `Provider` interface. All + * relay-fetch and evaluation logic lives in `ChargebeeEntitlements` (exposed + * here as `.entitlements`) — this class only bridges its callbacks to + * OpenFeature's event emitter. */ export class ChargebeeEntitlementsWebProvider implements Provider { readonly metadata = { name: "Chargebee Entitlements" } as const; readonly runsOn = "client" as const; readonly events = new OpenFeatureEventEmitter(); - readonly client: ChargebeeEntitlementsWebClient; + readonly entitlements: ChargebeeEntitlements; constructor(options: ChargebeeEntitlementsWebProviderOptions) { - this.client = new ChargebeeEntitlementsWebClient({ + this.entitlements = new ChargebeeEntitlements({ ...options, - onStale: () => { + onSnapshotExpired: () => { this.events.emit(ProviderEvents.Stale, { message: "Chargebee entitlement snapshot expired", }); }, - onConfigurationChanged: (flagsChanged) => { + onSnapshotRefreshed: ({ changedFeatureIds }) => { + if (changedFeatureIds.length === 0) { + return; + } + this.events.emit(ProviderEvents.ConfigurationChanged, { - flagsChanged, + flagsChanged: changedFeatureIds, }); }, - onError: (message) => { - this.events.emit(ProviderEvents.Error, { message }); + onError: (error) => { + this.events.emit(ProviderEvents.Error, { + message: + error instanceof Error + ? error.message + : "Unable to refresh Chargebee entitlements", + }); }, }); } initialize(): Promise { - return this.client.initialize(); + return this.entitlements.initialize(); } onContextChange( _oldContext: EvaluationContext, _newContext: EvaluationContext, ): Promise { - return this.client.reset(); + return this.entitlements.reset(); } onClose(): Promise { - return this.client.close(); + return this.entitlements.close(); } resolveBooleanEvaluation( @@ -95,12 +104,13 @@ export class ChargebeeEntitlementsWebProvider implements Provider { /** * One evaluation path for all four flag types: the default value's runtime - * type already tells `getValue` which shape to parse the entitlement into. + * type already tells `getDetails` which shape to parse the entitlement into. * The relay snapshot is scoped to the session, so the context is unused. */ private resolve(flagKey: string, defaultValue: T): ResolutionDetails { return toResolutionDetails( - this.client.getValue(flagKey, defaultValue), + this.entitlements.getDetails(flagKey, defaultValue), + PendingAs.NotReady, ); } } diff --git a/packages/openfeature/test/server.test.ts b/packages/openfeature/test/server.test.ts index a13757f..c735c44 100644 --- a/packages/openfeature/test/server.test.ts +++ b/packages/openfeature/test/server.test.ts @@ -97,6 +97,37 @@ describe("ChargebeeEntitlementsProvider", () => { ).resolves.toMatchObject({ value: { featureId: "seats", value: "10" } }); }); + it("maps entitlement details to OpenFeature variants and metadata", async () => { + const { client } = makeClient(); + const provider = new ChargebeeEntitlementsProvider({ + chargebeeClient: client, + }); + + await expect( + provider.resolveBooleanEvaluation("sso", false, context), + ).resolves.toEqual({ + value: true, + variant: "enabled", + reason: "TARGETING_MATCH", + flagMetadata: { + chargebeeFeatureId: "sso", + chargebeeEnabled: true, + chargebeeValue: "true", + cacheSource: "api", + }, + }); + await expect( + provider.resolveBooleanEvaluation("missing", false, context), + ).resolves.toMatchObject({ + value: false, + reason: "ERROR", + errorCode: "FLAG_NOT_FOUND", + }); + await expect( + provider.resolveNumberEvaluation("sso", 0, context), + ).resolves.toMatchObject({ value: 0, errorCode: "TYPE_MISMATCH" }); + }); + it("resolves a subscription-scoped context", async () => { const subscriptionRequest = vi.fn(async () => ({ list: [ diff --git a/packages/openfeature/test/web.test.ts b/packages/openfeature/test/web.test.ts index 4f4bf91..cb0129a 100644 --- a/packages/openfeature/test/web.test.ts +++ b/packages/openfeature/test/web.test.ts @@ -118,7 +118,7 @@ describe("ChargebeeEntitlementsWebProvider", () => { fetchImplementation: async () => Response.json(createEntitlementsSnapshot([], 60_000)), }); - const close = vi.spyOn(provider.client, "close"); + const close = vi.spyOn(provider.entitlements, "close"); await provider.onClose(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f0dae2..69db620 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: '@better-auth/utils': specifier: ^0.3.1 version: 0.3.1 + '@chargebee/entitlements': + specifier: link:../entitlements + version: link:../entitlements zod: specifier: ^4.3.6 version: 4.3.6 From d7601c79fad45e0096df7bb6ee4c23cd2cd22227 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Fri, 9 Oct 2026 14:10:25 +1100 Subject: [PATCH 17/22] add express and next examples for entitlements --- .../examples/express/.env.example | 4 + .../entitlements/examples/express/.gitignore | 2 + .../entitlements/examples/express/README.md | 38 + .../examples/express/package.json | 23 + .../examples/express/pnpm-lock.yaml | 712 ++++++++++++++++++ .../examples/express/pnpm-workspace.yaml | 3 + .../examples/express/src/chargebee.ts | 25 + .../examples/express/src/demo-page.ts | 163 ++++ .../examples/express/src/entitlements.ts | 25 + .../examples/express/src/mock-chargebee.ts | 127 ++++ .../examples/express/src/server.ts | 247 ++++++ .../examples/express/src/users.ts | 49 ++ .../examples/express/tsconfig.json | 15 + .../entitlements/examples/nextjs/.env.example | 3 + .../entitlements/examples/nextjs/.gitignore | 5 + .../entitlements/examples/nextjs/README.md | 42 ++ .../examples/nextjs/next.config.ts | 7 + .../entitlements/examples/nextjs/package.json | 27 + .../examples/nextjs/pnpm-lock.yaml | 686 +++++++++++++++++ .../examples/nextjs/pnpm-workspace.yaml | 5 + .../examples/nextjs/src/app/actions.ts | 9 + .../nextjs/src/app/api/entitlements/route.ts | 24 + .../examples/nextjs/src/app/globals.css | 98 +++ .../examples/nextjs/src/app/layout.tsx | 17 + .../examples/nextjs/src/app/page.tsx | 93 +++ .../examples/nextjs/src/app/reports/page.tsx | 34 + .../nextjs/src/components/browser-checks.tsx | 77 ++ .../src/components/entitlements-provider.tsx | 90 +++ .../nextjs/src/components/feature-gate.tsx | 30 + .../nextjs/src/components/feature-table.tsx | 46 ++ .../nextjs/src/components/user-profiles.tsx | 39 + .../examples/nextjs/src/lib/chargebee.ts | 25 + .../examples/nextjs/src/lib/entitlements.ts | 56 ++ .../examples/nextjs/src/lib/features.ts | 10 + .../examples/nextjs/src/lib/mock-chargebee.ts | 127 ++++ .../examples/nextjs/src/lib/session.ts | 25 + .../examples/nextjs/src/lib/users.ts | 48 ++ .../examples/nextjs/tsconfig.json | 29 + 38 files changed, 3085 insertions(+) create mode 100644 packages/entitlements/examples/express/.env.example create mode 100644 packages/entitlements/examples/express/.gitignore create mode 100644 packages/entitlements/examples/express/README.md create mode 100644 packages/entitlements/examples/express/package.json create mode 100644 packages/entitlements/examples/express/pnpm-lock.yaml create mode 100644 packages/entitlements/examples/express/pnpm-workspace.yaml create mode 100644 packages/entitlements/examples/express/src/chargebee.ts create mode 100644 packages/entitlements/examples/express/src/demo-page.ts create mode 100644 packages/entitlements/examples/express/src/entitlements.ts create mode 100644 packages/entitlements/examples/express/src/mock-chargebee.ts create mode 100644 packages/entitlements/examples/express/src/server.ts create mode 100644 packages/entitlements/examples/express/src/users.ts create mode 100644 packages/entitlements/examples/express/tsconfig.json create mode 100644 packages/entitlements/examples/nextjs/.env.example create mode 100644 packages/entitlements/examples/nextjs/.gitignore create mode 100644 packages/entitlements/examples/nextjs/README.md create mode 100644 packages/entitlements/examples/nextjs/next.config.ts create mode 100644 packages/entitlements/examples/nextjs/package.json create mode 100644 packages/entitlements/examples/nextjs/pnpm-lock.yaml create mode 100644 packages/entitlements/examples/nextjs/pnpm-workspace.yaml create mode 100644 packages/entitlements/examples/nextjs/src/app/actions.ts create mode 100644 packages/entitlements/examples/nextjs/src/app/api/entitlements/route.ts create mode 100644 packages/entitlements/examples/nextjs/src/app/globals.css create mode 100644 packages/entitlements/examples/nextjs/src/app/layout.tsx create mode 100644 packages/entitlements/examples/nextjs/src/app/page.tsx create mode 100644 packages/entitlements/examples/nextjs/src/app/reports/page.tsx create mode 100644 packages/entitlements/examples/nextjs/src/components/browser-checks.tsx create mode 100644 packages/entitlements/examples/nextjs/src/components/entitlements-provider.tsx create mode 100644 packages/entitlements/examples/nextjs/src/components/feature-gate.tsx create mode 100644 packages/entitlements/examples/nextjs/src/components/feature-table.tsx create mode 100644 packages/entitlements/examples/nextjs/src/components/user-profiles.tsx create mode 100644 packages/entitlements/examples/nextjs/src/lib/chargebee.ts create mode 100644 packages/entitlements/examples/nextjs/src/lib/entitlements.ts create mode 100644 packages/entitlements/examples/nextjs/src/lib/features.ts create mode 100644 packages/entitlements/examples/nextjs/src/lib/mock-chargebee.ts create mode 100644 packages/entitlements/examples/nextjs/src/lib/session.ts create mode 100644 packages/entitlements/examples/nextjs/src/lib/users.ts create mode 100644 packages/entitlements/examples/nextjs/tsconfig.json diff --git a/packages/entitlements/examples/express/.env.example b/packages/entitlements/examples/express/.env.example new file mode 100644 index 0000000..557758a --- /dev/null +++ b/packages/entitlements/examples/express/.env.example @@ -0,0 +1,4 @@ +# Copy to .env to call your Chargebee site. Leave unset to use the mock API. +CHARGEBEE_SITE= +CHARGEBEE_API_KEY= +PORT=3000 diff --git a/packages/entitlements/examples/express/.gitignore b/packages/entitlements/examples/express/.gitignore new file mode 100644 index 0000000..37d7e73 --- /dev/null +++ b/packages/entitlements/examples/express/.gitignore @@ -0,0 +1,2 @@ +node_modules +.env diff --git a/packages/entitlements/examples/express/README.md b/packages/entitlements/examples/express/README.md new file mode 100644 index 0000000..6d6ed88 --- /dev/null +++ b/packages/entitlements/examples/express/README.md @@ -0,0 +1,38 @@ +# Express example + +Gates Express routes with `@chargebee/entitlements` on the server. + +```sh +# once, from packages/entitlements: build the package this example links to +pnpm install && pnpm build + +# then, from this folder +pnpm i +pnpm start +``` + +Open http://localhost:3000. Requires Node.js 22.18 or later, which runs the TypeScript sources directly. + +## Mock or real Chargebee + +Without `CHARGEBEE_SITE` and `CHARGEBEE_API_KEY` in `.env`, the Chargebee SDK's HTTP client is replaced with [canned responses](./src/mock-chargebee.ts) and each call is logged as `[mock chargebee]`. The page then lists the demo users with what each should get, and lets you sign in as one and call the routes from the browser. + +With your site configured, the page lists curl commands instead, and requests name the user in the `x-user` header. To use your site, copy `.env.example` to `.env`, fill it in, and point the demo users in [`src/users.ts`](./src/users.ts) and the features in [`src/entitlements.ts`](./src/entitlements.ts) at your customer and feature IDs. + +## What to look for + +| User | Plan | Shows | +| --- | --- | --- | +| `alice` | Pro | Every feature `granted`; `/reports` allowed | +| `bob` | Free | `advanced-reports` is `disabled` (403 on `/reports`); `support-tier` is `not-found` and falls back to its default; all 3 seats taken | +| `carol` | none | Customer missing from Chargebee: every feature resolves to its default | + +- `GET /me/entitlements` twice: `source` changes from `api` to `cache`, and the mock logs one call. +- `POST /webhooks/chargebee` refetches the customer's snapshot, as a subscription change would. + +## Files + +- [`src/entitlements.ts`](./src/entitlements.ts): the client, its memory cache, and the feature declarations. +- [`src/server.ts`](./src/server.ts): routes, the `requireFeature` middleware, and the webhook. +- [`src/demo-page.ts`](./src/demo-page.ts): the mock-mode page. +- [`src/chargebee.ts`](./src/chargebee.ts): picks the real or the mock Chargebee client. diff --git a/packages/entitlements/examples/express/package.json b/packages/entitlements/examples/express/package.json new file mode 100644 index 0000000..f360212 --- /dev/null +++ b/packages/entitlements/examples/express/package.json @@ -0,0 +1,23 @@ +{ + "name": "entitlements-example-express", + "private": true, + "type": "module", + "engines": { + "node": ">=22.18" + }, + "scripts": { + "start": "node --env-file-if-exists=.env src/server.ts", + "dev": "node --env-file-if-exists=.env --watch src/server.ts", + "typecheck": "tsc" + }, + "dependencies": { + "@chargebee/entitlements": "file:../..", + "chargebee": "^3.34.0", + "express": "^5.2.1" + }, + "devDependencies": { + "@types/express": "^5.0.3", + "@types/node": "^22.18.0", + "typescript": "^5.9.2" + } +} diff --git a/packages/entitlements/examples/express/pnpm-lock.yaml b/packages/entitlements/examples/express/pnpm-lock.yaml new file mode 100644 index 0000000..daa3000 --- /dev/null +++ b/packages/entitlements/examples/express/pnpm-lock.yaml @@ -0,0 +1,712 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@chargebee/entitlements': + specifier: file:../.. + version: file:../..(chargebee@3.34.0) + chargebee: + specifier: ^3.34.0 + version: 3.34.0 + express: + specifier: ^5.2.1 + version: 5.2.1 + devDependencies: + '@types/express': + specifier: ^5.0.3 + version: 5.0.6 + '@types/node': + specifier: ^22.18.0 + version: 22.20.5 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + +packages: + + '@chargebee/entitlements@file:../..': + resolution: {directory: ../.., type: directory} + engines: {node: '>=22'} + peerDependencies: + chargebee: ^3.30.0 + ioredis: ^6.0.0 + next: ^16.0.0 + peerDependenciesMeta: + ioredis: + optional: true + next: + optional: true + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/express-serve-static-core@5.1.3': + resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/node@22.20.5': + resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chargebee@3.34.0: + resolution: {integrity: sha512-ODO/NvUCRlpvZpMu0jhzsnQ0GG5n4clL5fSqtAzYFbLIm6qoXGiq0O/fPNTLAmkaKt8Ejh42V21H4+15WlK8aw==} + engines: {node: '>=18.*'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + +snapshots: + + '@chargebee/entitlements@file:../..(chargebee@3.34.0)': + dependencies: + chargebee: 3.34.0 + server-only: 0.0.1 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.20.5 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.20.5 + + '@types/express-serve-static-core@5.1.3': + dependencies: + '@types/node': 22.20.5 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.3 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/node@22.20.5': + dependencies: + undici-types: 6.21.0 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 22.20.5 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.20.5 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chargebee@3.34.0: + dependencies: + zod: 4.6.5 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-to-regexp@8.4.2: {} + + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + server-only@0.0.1: {} + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + statuses@2.0.2: {} + + toidentifier@1.0.1: {} + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + wrappy@1.0.2: {} + + zod@4.6.5: {} diff --git a/packages/entitlements/examples/express/pnpm-workspace.yaml b/packages/entitlements/examples/express/pnpm-workspace.yaml new file mode 100644 index 0000000..a66b7a9 --- /dev/null +++ b/packages/entitlements/examples/express/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +# Standalone install root, separate from the repository's workspace. +packages: + - . diff --git a/packages/entitlements/examples/express/src/chargebee.ts b/packages/entitlements/examples/express/src/chargebee.ts new file mode 100644 index 0000000..fff6229 --- /dev/null +++ b/packages/entitlements/examples/express/src/chargebee.ts @@ -0,0 +1,25 @@ +import Chargebee from "chargebee"; +import { mockHttpClient } from "./mock-chargebee.ts"; + +const MOCK_SITE = "mock-site"; +const MOCK_API_KEY = "mock-api-key"; + +const site = process.env.CHARGEBEE_SITE; +const apiKey = process.env.CHARGEBEE_API_KEY; + +export const isMockChargebee = !site || !apiKey; + +/** Uses your Chargebee site when `.env` has credentials, the mock otherwise. */ +export function createChargebee(): Chargebee { + if (site && apiKey) { + console.log(`Chargebee: site "${site}"`); + return new Chargebee({ site, apiKey }); + } + + console.log("Chargebee: no credentials in .env, using the mock API"); + return new Chargebee({ + site: MOCK_SITE, + apiKey: MOCK_API_KEY, + httpClient: mockHttpClient, + }); +} diff --git a/packages/entitlements/examples/express/src/demo-page.ts b/packages/entitlements/examples/express/src/demo-page.ts new file mode 100644 index 0000000..a867593 --- /dev/null +++ b/packages/entitlements/examples/express/src/demo-page.ts @@ -0,0 +1,163 @@ +import type { User } from "./users.ts"; + +export interface FeatureSummary { + featureId: string; + value: unknown; + status: string; + source?: string; + error?: string; +} + +interface DemoPageOptions { + users: readonly User[]; + user: User | undefined; + features: FeatureSummary[]; + userField: string; +} + +/** An API route the page can call, with its response shown below the buttons. */ +interface ApiAction { + method: "GET" | "POST"; + url: string; + body?: string; +} + +const STYLES = ` +body { margin: 0; font-family: system-ui, sans-serif; color: #1f2328; background: #f6f8fa; } +main { max-width: 760px; margin: 0 auto; padding: 2rem 1rem; } +section { margin-top: 1.5rem; padding: 1rem 1.25rem; background: #fff; border: 1px solid #d0d7de; border-radius: 8px; } +h2 { margin-top: 0; font-size: 1.1rem; } +table { width: 100%; border-collapse: collapse; } +th, td { padding: 0.4rem 0.5rem; text-align: left; border-bottom: 1px solid #eaeef2; } +button { margin: 0 0.5rem 0.5rem 0; padding: 0.35rem 0.8rem; cursor: pointer; } +pre { padding: 0.75rem; background: #f6f8fa; border-radius: 6px; overflow-x: auto; } +.profiles { margin: 0 0 1rem; padding: 0; list-style: none; } +.profiles li { margin-bottom: 0.5rem; padding: 0.6rem 0.8rem; border: 1px solid #d0d7de; border-radius: 6px; } +.profiles li.current { border-color: #1a7f37; background: #f0fff4; } +.profiles p { margin: 0.25rem 0 0.5rem; } +.notice { padding: 0.6rem 1rem; background: #fff8c5; border: 1px solid #d4a72c; border-radius: 8px; } +.muted { color: #656d76; } +.granted { color: #1a7f37; } +.disabled, .error { color: #cf222e; } +`; + +/** Sends each action's request and prints the status and JSON body. */ +const SCRIPT = ` +const output = document.getElementById("output"); +for (const button of document.querySelectorAll("[data-url]")) { + button.addEventListener("click", async () => { + const { method, url, body } = button.dataset; + const headers = body ? { "Content-Type": "application/json" } : {}; + const response = await fetch(url, { method, headers, body }); + const text = await response.text(); + let pretty = text; + try { pretty = JSON.stringify(JSON.parse(text), null, 2); } catch {} + output.textContent = method + " " + url + " → " + response.status + "\\n\\n" + pretty; + }); +} +`; + +const escapeHtml = (value: unknown) => + String(value).replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); + +function renderProfiles(options: DemoPageOptions): string { + const items = options.users + .map((user) => { + const current = user.id === options.user?.id; + return `
  • + ${escapeHtml(user.name)} +

    ${escapeHtml(user.description)}

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

    Entitlements of ${escapeHtml(user.name)}

    + ${renderFeatures(options.features)} +

    Reload: source moves from api to cache.

    +
    +
    +

    Try the API

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

    Sign in as a demo user to see their entitlements.

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

    Chargebee entitlements

    +

    Express.js example - server side checks only

    +

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

    +
    +

    Demo users

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

    Chargebee entitlements

    +

    Next.js example - server and client side checks

    + {isMockChargebee && ( +

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

    + )} + + {isMockChargebee ? ( +
    +

    Demo users

    + +
    + ) : ( +
    +

    Signed in as

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

    Server

    + +

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

    +
    + +
    +

    Browser

    + + + +
    + + ) : ( +

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

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

    Advanced reports

    +
    + {allowed ? ( +

    Revenue by region: APAC 1,200.

    + ) : ( +

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

    + )} +
    +

    + Back +

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

    {loadError}. Every feature keeps its default.

    + )} + +

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

    +

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

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

      {description}

      + +
    • + ); + })} +
    + +
    + ); +} diff --git a/packages/entitlements/examples/nextjs/src/lib/chargebee.ts b/packages/entitlements/examples/nextjs/src/lib/chargebee.ts new file mode 100644 index 0000000..a97ea06 --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/lib/chargebee.ts @@ -0,0 +1,25 @@ +import "server-only"; + +import Chargebee from "chargebee"; +import { mockHttpClient } from "./mock-chargebee"; + +const MOCK_SITE = "mock-site"; +const MOCK_API_KEY = "mock-api-key"; + +const site = process.env.CHARGEBEE_SITE; +const apiKey = process.env.CHARGEBEE_API_KEY; + +export const isMockChargebee = !site || !apiKey; + +/** Uses your Chargebee site when `.env` has credentials, the mock otherwise. */ +export function createChargebee(): Chargebee { + if (site && apiKey) { + return new Chargebee({ site, apiKey }); + } + + return new Chargebee({ + site: MOCK_SITE, + apiKey: MOCK_API_KEY, + httpClient: mockHttpClient, + }); +} diff --git a/packages/entitlements/examples/nextjs/src/lib/entitlements.ts b/packages/entitlements/examples/nextjs/src/lib/entitlements.ts new file mode 100644 index 0000000..9387a0b --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/lib/entitlements.ts @@ -0,0 +1,56 @@ +import "server-only"; + +import type { Feature } from "@chargebee/entitlements"; +import { createMemoryEntitlementsCache } from "@chargebee/entitlements/cache"; +import { + ChargebeeEntitlements, + type ScopedEntitlements, +} from "@chargebee/entitlements/server"; +import { createChargebee } from "./chargebee"; +import type { User } from "./users"; + +const CACHE_TTL_MS = 30_000; + +/** Chargebee API errors are plain objects with a `message`, not `Error`s. */ +function errorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return (error as { message?: string } | undefined)?.message ?? String(error); +} + +const globalForEntitlements = globalThis as { + chargebeeEntitlements?: ChargebeeEntitlements; +}; + +/** + * One client per process. Kept on `globalThis` so dev hot reloads reuse it + * and its cache instead of refetching from Chargebee. + */ +globalForEntitlements.chargebeeEntitlements ??= new ChargebeeEntitlements({ + chargebeeClient: createChargebee(), + cache: createMemoryEntitlementsCache({ ttlMs: CACHE_TTL_MS }), + // A warning, not `console.error`: Next.js dev shows errors in an overlay, + // and the demo's missing customer fails on purpose. + onError: (error, { operation, target }) => { + console.warn( + `[entitlements] ${operation} failed for ${JSON.stringify(target)}: ${errorMessage(error)}`, + ); + }, +}); + +export const entitlements = globalForEntitlements.chargebeeEntitlements; + +/** The server client bound to the user's Chargebee customer. */ +export function entitlementsFor(user: User): ScopedEntitlements { + return entitlements.for({ customerId: user.chargebeeCustomerId }); +} + +/** Whether the user is entitled to a switch feature. */ +export function hasFeature( + user: User, + feature: Feature, +): Promise { + return entitlementsFor(user).get(feature.featureId, feature.defaultValue); +} diff --git a/packages/entitlements/examples/nextjs/src/lib/features.ts b/packages/entitlements/examples/nextjs/src/lib/features.ts new file mode 100644 index 0000000..f8d3c2b --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/lib/features.ts @@ -0,0 +1,10 @@ +import { Feature } from "@chargebee/entitlements"; + +// Shared by server and browser code. Defaults apply when Chargebee has no +// usable value, so they match the lowest tier. +export const advancedReports = new Feature("advanced-reports", false); +export const licensedSeats = new Feature("licensed-seats", 1); +export const apiCalls = new Feature("api-calls", 100); +export const supportTier = new Feature("support-tier", "community"); + +export const FEATURES = [advancedReports, licensedSeats, apiCalls, supportTier]; diff --git a/packages/entitlements/examples/nextjs/src/lib/mock-chargebee.ts b/packages/entitlements/examples/nextjs/src/lib/mock-chargebee.ts new file mode 100644 index 0000000..dceac90 --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/lib/mock-chargebee.ts @@ -0,0 +1,127 @@ +import type { HttpClientInterface } from "chargebee"; + +/** + * Answers Chargebee API calls with canned data when `.env` has no + * credentials. The real SDK still builds every request; only the HTTP round + * trip is replaced: + * + * ChargebeeEntitlements ─► chargebee SDK ─► mockHttpClient ─► canned JSON + */ + +const LATENCY_MS = 300; +const HTTP_NOT_FOUND = 404; +const CUSTOMER_ENTITLEMENTS_PATH = + /\/customers\/([^/]+)\/customer_entitlements$/; + +/** An entitlement row as the Chargebee API returns it. */ +interface RawEntitlement { + feature_id: string; + feature_name: string; + feature_type: "switch" | "quantity" | "range" | "custom"; + feature_unit?: string; + value: string; + is_enabled: boolean; +} + +const PRO_PLAN: RawEntitlement[] = [ + { + feature_id: "advanced-reports", + feature_name: "Advanced reports", + feature_type: "switch", + value: "true", + is_enabled: true, + }, + { + feature_id: "licensed-seats", + feature_name: "Licensed seats", + feature_type: "quantity", + feature_unit: "seat", + value: "25", + is_enabled: true, + }, + { + feature_id: "api-calls", + feature_name: "API calls per month", + feature_type: "range", + value: "unlimited", + is_enabled: true, + }, + { + feature_id: "support-tier", + feature_name: "Support tier", + feature_type: "custom", + value: "priority", + is_enabled: true, + }, +]; + +/** No `support-tier` row, so that feature resolves to its default. */ +const FREE_PLAN: RawEntitlement[] = [ + { + feature_id: "advanced-reports", + feature_name: "Advanced reports", + feature_type: "switch", + value: "false", + is_enabled: false, + }, + { + feature_id: "licensed-seats", + feature_name: "Licensed seats", + feature_type: "quantity", + feature_unit: "seat", + value: "3", + is_enabled: true, + }, + { + feature_id: "api-calls", + feature_name: "API calls per month", + feature_type: "range", + value: "1000", + is_enabled: true, + }, +]; + +/** Any other customer ID gets a 404, like a customer missing from the site. */ +const CUSTOMERS: Record = { + cust_pro: PRO_PLAN, + cust_free: FREE_PLAN, +}; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Chargebee's error body for an unknown resource. */ +function notFound(message: string): Response { + return Response.json( + { + message, + type: "invalid_request", + api_error_code: "resource_not_found", + http_status_code: HTTP_NOT_FOUND, + }, + { status: HTTP_NOT_FOUND }, + ); +} + +export const mockHttpClient: HttpClientInterface = { + async makeApiRequest(request) { + const { pathname } = new URL(request.url); + console.log(`[mock chargebee] ${request.method} ${pathname}`); + await sleep(LATENCY_MS); + + const match = CUSTOMER_ENTITLEMENTS_PATH.exec(pathname); + if (!match?.[1]) { + return notFound(`No mock for ${pathname}`); + } + + const customerId = decodeURIComponent(match[1]); + const entitlements = CUSTOMERS[customerId]; + if (!entitlements) { + return notFound(`Customer ${customerId} not found`); + } + + // One page: no `next_offset`. + return Response.json({ + list: entitlements.map((row) => ({ customer_entitlement: row })), + }); + }, +}; diff --git a/packages/entitlements/examples/nextjs/src/lib/session.ts b/packages/entitlements/examples/nextjs/src/lib/session.ts new file mode 100644 index 0000000..61aaf96 --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/lib/session.ts @@ -0,0 +1,25 @@ +import "server-only"; + +import { cookies } from "next/headers"; +import { findUser, type User } from "./users"; + +const SESSION_COOKIE = "demo-user"; + +/** Form field the user switcher submits. */ +export const USER_FIELD = "userId"; + +/** Stand-in for real auth: a cookie naming a demo user. */ +export async function getSessionUser(): Promise { + const store = await cookies(); + return findUser(store.get(SESSION_COOKIE)?.value) ?? null; +} + +export async function setSessionUser(userId: string | null): Promise { + const store = await cookies(); + if (!userId) { + store.delete(SESSION_COOKIE); + return; + } + + store.set(SESSION_COOKIE, userId, { httpOnly: true, sameSite: "lax" }); +} diff --git a/packages/entitlements/examples/nextjs/src/lib/users.ts b/packages/entitlements/examples/nextjs/src/lib/users.ts new file mode 100644 index 0000000..443d796 --- /dev/null +++ b/packages/entitlements/examples/nextjs/src/lib/users.ts @@ -0,0 +1,48 @@ +import "server-only"; + +export interface User { + id: string; + name: string; + /** What the mock API grants this user; your own site's data will differ. */ + description: string; + chargebeeCustomerId: string; +} + +export type UserProfile = Omit; + +/** + * Stands in for your user table. With real credentials, point + * `chargebeeCustomerId` at customers in your Chargebee site. + */ +const USERS: User[] = [ + { + id: "alice", + name: "Alice, Pro plan", + description: + "Advanced reports, 25 seats, unlimited API calls, and priority support.", + chargebeeCustomerId: "cust_pro", + }, + { + id: "bob", + name: "Bob, Free plan", + description: + "Advanced reports disabled, 3 seats, and 1,000 API calls. Has no support tier, so it falls back to the default.", + chargebeeCustomerId: "cust_free", + }, + { + id: "carol", + name: "Carol, not a customer", + description: + "Missing from Chargebee. The server falls back to defaults and the relay answers 502.", + chargebeeCustomerId: "cust_missing", + }, +]; + +export function findUser(id: string | undefined): User | undefined { + return USERS.find((user) => user.id === id); +} + +/** Without customer IDs, which never reach the browser. */ +export function listUsers(): UserProfile[] { + return USERS.map(({ id, name, description }) => ({ id, name, description })); +} diff --git a/packages/entitlements/examples/nextjs/tsconfig.json b/packages/entitlements/examples/nextjs/tsconfig.json new file mode 100644 index 0000000..a735b0f --- /dev/null +++ b/packages/entitlements/examples/nextjs/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} From 27f764abea55c0bf90d5c1fac70d22b5665d1596 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Fri, 9 Oct 2026 14:10:57 +1100 Subject: [PATCH 18/22] fix undefined fetch error --- packages/entitlements/CHANGELOG.md | 2 ++ packages/entitlements/src/web/client.ts | 5 ++++- packages/entitlements/test/web.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/entitlements/CHANGELOG.md b/packages/entitlements/CHANGELOG.md index 4c59073..dc4f7c7 100644 --- a/packages/entitlements/CHANGELOG.md +++ b/packages/entitlements/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- The web client calls `fetch` unbound. Browsers' native `fetch` threw + "Illegal invocation", so no relay snapshot could load. - No OpenFeature vocabulary remains in this package; `@chargebee/openfeature` maps the results instead. `EntitlementResolution` is now `EntitlementDetails`: `{ value, status, source?, entitlement?, error? }`. diff --git a/packages/entitlements/src/web/client.ts b/packages/entitlements/src/web/client.ts index e29eef7..09cc632 100644 --- a/packages/entitlements/src/web/client.ts +++ b/packages/entitlements/src/web/client.ts @@ -188,7 +188,10 @@ export class ChargebeeEntitlements { try { const headers = new Headers(this.requestHeaders); if (!headers.has("Accept")) headers.set("Accept", "application/json"); - const response = await this.fetchImplementation(this.relayUrl, { + + // Called unbound: browsers' fetch throws when `this` is not the window. + const fetchImplementation = this.fetchImplementation; + const response = await fetchImplementation(this.relayUrl, { method: "GET", cache: "no-store", credentials: this.credentials, diff --git a/packages/entitlements/test/web.test.ts b/packages/entitlements/test/web.test.ts index c8ebc53..2662dd5 100644 --- a/packages/entitlements/test/web.test.ts +++ b/packages/entitlements/test/web.test.ts @@ -50,6 +50,28 @@ describe("ChargebeeEntitlements (web)", () => { ); }); + it("calls the global fetch without binding it to the client", async () => { + // Browsers' native fetch throws "Illegal invocation" unless `this` is the window. + vi.stubGlobal( + "fetch", + async function (this: unknown) { + if (this !== undefined && this !== globalThis) { + throw new TypeError("Illegal invocation"); + } + return Response.json(snapshotWith("true")); + }, + ); + const client = new ChargebeeEntitlements({ relayUrl: "/api/entitlements" }); + + try { + await client.initialize(); + } finally { + vi.unstubAllGlobals(); + } + + expect(client.get("sso", false)).toBe(true); + }); + it("reports pending before the snapshot loads", () => { const client = new ChargebeeEntitlements({ relayUrl: "/api/entitlements", From f7026d0886b8d82337615a84a891441596d470ab Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Fri, 9 Oct 2026 16:46:32 +1100 Subject: [PATCH 19/22] add entitlements architecture and flow --- packages/entitlements/docs/architecture.html | 2084 ++++++++++++++++++ packages/entitlements/docs/architecture.md | 90 + 2 files changed, 2174 insertions(+) create mode 100644 packages/entitlements/docs/architecture.html create mode 100644 packages/entitlements/docs/architecture.md diff --git a/packages/entitlements/docs/architecture.html b/packages/entitlements/docs/architecture.html new file mode 100644 index 0000000..3930fea --- /dev/null +++ b/packages/entitlements/docs/architecture.html @@ -0,0 +1,2084 @@ + + + + + +Entitlement Resolution · Architecture + + + + + + + + + + +
    + + @chargebee/entitlements +

    Entitlement resolution

    +

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

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

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

    + +
    +
    +
    + Scenario · Cold miss · blocking +
    + + + + 0 / 0 +
    +
    +
    +
    + +
    + + +
    +
    +
    User / Browser
    +
    session · web client
    +
    logged in
    + · +
    +
    +
    +
    App server
    +
    route · relay
    +
    Express / Next.js
    + · +
    +
    +
    +
    Entitlements SDK
    +
    ChargebeeEntitlements
    +
    dedupe · backoff
    + · +
    +
    +
    +
    Cache
    +
    Redis / memory LRU
    +
    cacheTtlMs 60s
    + · +
    +
    +
    +
    Durable store
    +
    Postgres (yours)
    +
    snapshotTtlMs 5m
    + · +
    +
    +
    +
    Chargebee API
    +
    entitlements API
    +
    100/page
    + · +
    +
    +
    +
    Webhook worker
    +
    refreshSnapshot
    +
    POST /webhooks
    + · +
    + + + + +
    +
    + + +
    + + + + diff --git a/packages/entitlements/docs/architecture.md b/packages/entitlements/docs/architecture.md new file mode 100644 index 0000000..0436e34 --- /dev/null +++ b/packages/entitlements/docs/architecture.md @@ -0,0 +1,90 @@ +# Entitlement resolution + +Companion to [`architecture.html`](./architecture.html). Open it in a browser to step through each scenario. + +## Components + +| Node | Role | +|---|---| +| User / Browser | Logged-in user. Server pages, or the `@chargebee/entitlements/web` client. | +| App server | Route, middleware, or relay handler. Derives the target from the session. | +| Entitlements SDK | `ChargebeeEntitlements` (server). Resolves snapshots, dedupes in-flight refreshes, backs off after failures. | +| Cache | `cache` option: Redis or in-memory LRU. Entry TTL `cacheTtlMs` (60s default). | +| Durable store | `durableStore` option: source of truth, e.g. a Postgres table. User-supplied `EntitlementsStorage`; the library ships no Postgres adapter. | +| Chargebee API | Customer or subscription entitlements, 100 per page, max 50 pages. | +| Webhook worker | Calls `refreshSnapshot` / `writeSnapshot` / `deleteSnapshot`. | + +Lookup order for every evaluation: + +``` +get(feature, default, target) + └─ cache.get ──hit──▶ serve (refresh in background if expired) + │ miss/error + ▼ + store.get ──hit──▶ backfill cache, serve (refresh in background if expired) + │ miss/error + ▼ + refreshOnMiss = blocking ──▶ await Chargebee → store.set → cache.set → serve + refreshOnMiss = background ──▶ start fetch, return default (status "pending") +``` + +Two TTLs matter: + +- `cacheTtlMs`: when the cache entry disappears. A vanished entry is a plain miss. +- `snapshotTtlMs` (5 min): sets `snapshot.expiresAt`. Past it, the snapshot is served stale and refreshed in the background. + +## Mode toggle: `refreshOnMiss` + +Changes only the cold-miss path. `blocking` (default) waits on Chargebee; `background` returns defaults with status `pending`. + +## Scenarios + +### 1a. Cold miss, blocking +No snapshot in cache or store (e.g. first login). +1. User → App: request; target from session. +2. App → SDK: `get(featureId, default, target)`. +3. SDK → Cache: miss. +4. SDK → Store: miss. +5. SDK → Chargebee: fetch, awaited. Concurrent requests share it. +6. Chargebee → SDK: paginated entitlements → snapshot. +7. SDK → Store: persist. Failure propagates → `error` / `unavailable`. +8. SDK → Cache: populate. Failure swallowed. +9. SDK → App: `granted`, `source: "api"`. +10. App → User: rendered. + +### 1b. Cold miss, background +Steps 1–4 as above, then: +5. SDK → App: default, `status: "pending"` (`SnapshotPendingError` caught). +6. App → User: rendered with defaults. +7–10. Background fetch → store → cache. Skipped within `refreshBackoffMs` after a failure. + +### 2. Store hit, cache miss +1–3 as above. Store returns the snapshot; SDK backfills the cache; returns `source: "store"`. If the row is expired, it is still served and a background refresh starts. + +### 3. Cache hit +Cache returns a fresh snapshot; `source: "cache"`. Store and Chargebee untouched. + +### 4. Expired snapshot (stale-while-revalidate) +Cache returns a snapshot past `expiresAt`. It is served (`source: "cache"`) and a deduplicated background refresh updates store and cache. Happens when `cacheTtlMs ≥ snapshotTtlMs` or an expired store row was backfilled. With defaults the cache entry vanishes first, so this path is usually reached through the store (scenario 2 + background refresh). + +### 5. Outage +Cache read throws → `onError(cache-read)`, treated as a miss. Store returns an expired row; cache backfill fails silently. Refresh fails → `onError(refresh)`, retries suppressed for 10s. Expired store snapshot served. With no store row: blocking → `error` / `unavailable`; background → `pending`. `get()` never throws. + +### 6. Webhook refresh +Chargebee → Webhook → `refreshSnapshot(target)`: cancel in-flight request refreshes, evict cache, fetch, persist to store and cache, `onSnapshotRefreshed({ trigger: "explicit" })`. A memory cache evicts only in the handling process. Alternatives: `writeSnapshot` (snapshot built from the payload), `deleteSnapshot` (clears cache and store). + +### 7. Browser relay +Browser → `GET /api/entitlements` → `createEntitlementsRelayHandler` → `getRelaySnapshot(target)` (same resolution chain) → `expiresAt` rewritten to `now + relayTtlMs` (60s) → `200` with `Cache-Control: no-store`. Guards: non-GET 405, identity query params 400, no session 401, failure 502. The browser evaluates synchronously: no snapshot → `pending`; expired → `stale` plus one background refetch. + +## Per-feature outcomes (any scenario) + +| Status | When | Value | +|---|---|---| +| `granted` | Enabled, parses as the default's type | Entitlement value | +| `disabled` | Disabled, or entitlement `expiresAt` passed | Default | +| `error` / `not-found` | Feature absent from snapshot | Default | +| `error` / `type-mismatch` | Value doesn't parse as the default's type | Default | +| `error` / `invalid-target` | Neither or both of `customerId` / `subscriptionId` | Default | +| `error` / `unavailable` | Snapshot couldn't be loaded | Default | +| `pending` | Background cold miss, or browser not loaded | Default | +| `stale` | Browser snapshot expired, refetching | Default | From 5991355f53ab91d534500b59936cc40bcd30d267 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Mon, 19 Oct 2026 14:28:49 +1100 Subject: [PATCH 20/22] add getFromAPI method --- packages/entitlements/README.md | 10 ++ packages/entitlements/docs/architecture.md | 3 + .../entitlements/src/server/entitlements.ts | 80 +++++++++++++-- packages/entitlements/test/server.test.ts | 97 +++++++++++++++++++ 4 files changed, 181 insertions(+), 9 deletions(-) diff --git a/packages/entitlements/README.md b/packages/entitlements/README.md index 3bb79ac..33eff31 100644 --- a/packages/entitlements/README.md +++ b/packages/entitlements/README.md @@ -122,6 +122,16 @@ Chargebee outage. Cache and store read failures fall through to the next step and are reported through `onError`. Concurrent refreshes for one target are deduplicated, and a failed refresh waits `advanced.refreshBackoffMs` (10 seconds) before retrying. +## Reading from the API + +When required, the `getFromAPI` method can be used to skip the cache and store, and fetch the entitlement directly from the Chargebee API. + +```ts +await entitlements.getFromAPI("licensed-seats", 0, { customerId }); +``` + +The fetched snapshot is written to the store and the cache entry is evicted, so later reads pick it up. Each call makes its own Chargebee request and replaces any refresh already in flight, so keep it off hot paths. When Chargebee fails, it returns the default instead of a cached value. + ## Webhooks ```ts diff --git a/packages/entitlements/docs/architecture.md b/packages/entitlements/docs/architecture.md index 0436e34..a4bc86b 100644 --- a/packages/entitlements/docs/architecture.md +++ b/packages/entitlements/docs/architecture.md @@ -73,6 +73,9 @@ Cache read throws → `onError(cache-read)`, treated as a miss. Store returns an ### 6. Webhook refresh Chargebee → Webhook → `refreshSnapshot(target)`: cancel in-flight request refreshes, evict cache, fetch, persist to store and cache, `onSnapshotRefreshed({ trigger: "explicit" })`. A memory cache evicts only in the handling process. Alternatives: `writeSnapshot` (snapshot built from the payload), `deleteSnapshot` (clears cache and store). +### 6b. API read +`getFromAPI(featureId, default, target)`: cancel in-flight refreshes, skip cache and store, fetch, write store, evict cache, `onSnapshotRefreshed({ trigger: "request" })`. No backoff, no fallback: failure → default, `onError(refresh)`. + ### 7. Browser relay Browser → `GET /api/entitlements` → `createEntitlementsRelayHandler` → `getRelaySnapshot(target)` (same resolution chain) → `expiresAt` rewritten to `now + relayTtlMs` (60s) → `200` with `Cache-Control: no-store`. Guards: non-GET 405, identity query params 400, no session 401, failure 502. The browser evaluates synchronously: no snapshot → `pending`; expired → `stale` plus one background refetch. diff --git a/packages/entitlements/src/server/entitlements.ts b/packages/entitlements/src/server/entitlements.ts index 5d3a774..e4776cb 100644 --- a/packages/entitlements/src/server/entitlements.ts +++ b/packages/entitlements/src/server/entitlements.ts @@ -170,14 +170,41 @@ export class ChargebeeEntitlements { defaultValue: T, target: ChargebeeTarget, ): Promise> { - return this.evaluate(defaultValue, target, (result) => - resolveEntitlement( - result.snapshot, - featureId, - defaultValue, - result.source, - ), + return this.evaluate( + defaultValue, + () => this.getSnapshot(target), + (result) => + resolveEntitlement( + result.snapshot, + featureId, + defaultValue, + result.source, + ), + ); + } + + /** + * Like {@link get}, but skips the cache and store and reads Chargebee, for + * critical flows such as checkout. The result is written to the store and + * the cache entry is evicted, so later reads pick it up. + */ + async getFromAPI( + featureId: string, + defaultValue: T, + target: ChargebeeTarget, + ): Promise { + const details = await this.evaluate( + defaultValue, + () => this.fetchFromAPI(target), + (result) => + resolveEntitlement( + result.snapshot, + featureId, + defaultValue, + result.source, + ), ); + return details.value; } /** @@ -313,11 +340,11 @@ export class ChargebeeEntitlements { private async evaluate( defaultValue: T, - target: ChargebeeTarget, + load: () => Promise, resolve: (result: EntitlementsSnapshotResult) => EntitlementDetails, ): Promise> { try { - return resolve(await this.getSnapshot(target)); + return resolve(await load()); } catch (error) { if (error instanceof SnapshotPendingError) { return { value: defaultValue, status: "pending" }; @@ -405,6 +432,41 @@ export class ChargebeeEntitlements { }); } + /** + * A refresh already in flight may predate the change the caller cares + * about, so it is cancelled rather than joined; otherwise it would + * overwrite this result when it lands. + */ + private async fetchFromAPI( + target: ChargebeeTarget, + ): Promise { + const resolved = this.resolveTarget(target); + const key = this.cacheKey(resolved); + this.cancelInFlight(key); + + let snapshot: ChargebeeEntitlementsSnapshot; + try { + snapshot = createEntitlementsSnapshot( + await this.loader.load(resolved), + this.snapshotTtlMs, + ); + } catch (error) { + this.onError?.(error, { operation: "refresh", target: resolved }); + throw error; + } + + // Store first, then evict: a reader that misses the cache must find the + // new snapshot in the store, not refill the cache with the old one. + await this.write(this.durableStore, key, snapshot, resolved, "store-write"); + await this.evictCache(key, resolved); + this.onSnapshotRefreshed?.({ + target: resolved, + snapshot, + trigger: "request", + }); + return { snapshot, source: "api" }; + } + private fetchAndPersistSnapshot( target: ChargebeeTarget, key: string, diff --git a/packages/entitlements/test/server.test.ts b/packages/entitlements/test/server.test.ts index 373b8d9..3a551c2 100644 --- a/packages/entitlements/test/server.test.ts +++ b/packages/entitlements/test/server.test.ts @@ -479,6 +479,103 @@ describe("ChargebeeEntitlements", () => { expect(customerRequest).toHaveBeenCalledTimes(1); }); + it("reads from the API, writes the store, and evicts the cache", async () => { + const { client, customerRequest } = makeClient([ssoPage]); + const cache = createMemoryEntitlementsCache(); + const store = makeDurableStore(); + const cacheRead = vi.spyOn(cache, "get"); + const cacheWrite = vi.spyOn(cache, "set"); + const evict = vi.spyOn(cache, "delete"); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: client, + cache, + durableStore: store, + }); + await entitlements.writeSnapshot( + target, + createEntitlementsSnapshot([], 60_000), + ); + cacheWrite.mockClear(); + + await expect(entitlements.getFromAPI("sso", false, target)).resolves.toBe( + true, + ); + expect(customerRequest).toHaveBeenCalledTimes(1); + expect(cacheRead).not.toHaveBeenCalled(); + expect(cacheWrite).not.toHaveBeenCalled(); + expect(evict).toHaveBeenCalledWith( + "chargebee:entitlements:v1:customer:customer-1:consolidated", + ); + + await expect( + entitlements.getDetails("sso", false, target), + ).resolves.toMatchObject({ value: true, source: "store" }); + }); + + it("keeps an older in-flight refresh from overwriting an API read", async () => { + let release: (() => void) | undefined; + const pending = new Promise((resolve) => { + release = resolve; + }); + let requestCount = 0; + const customerRequest = vi.fn(async () => { + requestCount += 1; + if (requestCount === 1) { + await pending; + return { list: [] }; + } + return ssoPage; + }); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: { + customerEntitlement: { entitlementsForCustomer: customerRequest }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: vi.fn(), + }, + } as unknown as ChargebeeEntitlementsClient, + durableStore: makeDurableStore(), + refreshOnMiss: "background", + }); + + await entitlements.get("sso", false, target); + await vi.waitFor(() => expect(customerRequest).toHaveBeenCalledTimes(1)); + + await expect(entitlements.getFromAPI("sso", false, target)).resolves.toBe( + true, + ); + + release?.(); + await new Promise((resolve) => setTimeout(resolve, 5)); + await expect(entitlements.get("sso", false, target)).resolves.toBe(true); + }); + + it("returns the default when the API read fails", async () => { + const customerRequest = vi + .fn() + .mockResolvedValueOnce(ssoPage) + .mockRejectedValueOnce(new Error("chargebee unavailable")); + const onError = vi.fn(); + const entitlements = new ChargebeeEntitlements({ + chargebeeClient: { + customerEntitlement: { entitlementsForCustomer: customerRequest }, + subscriptionEntitlement: { + subscriptionEntitlementsForSubscription: vi.fn(), + }, + } as unknown as ChargebeeEntitlementsClient, + cache: createMemoryEntitlementsCache(), + onError, + }); + await entitlements.refreshSnapshot(target); + + await expect(entitlements.getFromAPI("sso", false, target)).resolves.toBe( + false, + ); + expect(onError).toHaveBeenCalledWith(expect.any(Error), { + operation: "refresh", + target, + }); + }); + it("warns through the configured logger when Chargebee omits a feature_id", async () => { const logger = { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }; const { client } = makeClient([ From 5942830d21bd5f65d7f2b3857c6826ce9ca289f4 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Mon, 19 Oct 2026 19:07:24 +1100 Subject: [PATCH 21/22] bump versions --- packages/better-auth/package.json | 2 +- packages/entitlements/package.json | 2 +- packages/openfeature/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index 0e500c9..030a9f9 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -1,7 +1,7 @@ { "name": "@chargebee/better-auth", "author": "DX Chargebee", - "version": "1.3.0-alpha.3", + "version": "1.3.0", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/entitlements/package.json b/packages/entitlements/package.json index ff71e4e..5cebf31 100644 --- a/packages/entitlements/package.json +++ b/packages/entitlements/package.json @@ -1,7 +1,7 @@ { "name": "@chargebee/entitlements", "author": "DX Chargebee", - "version": "0.1.0-alpha.3", + "version": "0.1.0", "type": "module", "description": "Framework-agnostic Chargebee entitlements resolution", "license": "MIT", diff --git a/packages/openfeature/package.json b/packages/openfeature/package.json index 225e22f..30638fd 100644 --- a/packages/openfeature/package.json +++ b/packages/openfeature/package.json @@ -1,7 +1,7 @@ { "name": "@chargebee/openfeature", "author": "DX Chargebee", - "version": "0.1.0-alpha.2", + "version": "0.1.0", "type": "module", "description": "OpenFeature provider for Chargebee", "license": "MIT", From 5a28a551fb598aee9058c347c5c8f14d0ce14f2f Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Tue, 27 Oct 2026 11:36:07 +1100 Subject: [PATCH 22/22] add benchmark for entitlements package --- packages/better-auth/src/version.ts | 2 +- .../entitlements/bench/docker-compose.yml | 41 ++ .../entitlements/bench/entitlements.bench.ts | 165 ++++++++ .../entitlements/bench/support/chargebee.ts | 50 +++ packages/entitlements/bench/support/config.ts | 45 ++ .../entitlements/bench/support/network.ts | 44 ++ .../entitlements/bench/support/postgres.ts | 51 +++ packages/entitlements/bench/support/stack.ts | 121 ++++++ packages/entitlements/bench/toxiproxy.json | 14 + packages/entitlements/bench/tsconfig.json | 12 + packages/entitlements/package.json | 15 +- packages/entitlements/vitest.config.ts | 3 + pnpm-lock.yaml | 396 +++++++++++++++++- 13 files changed, 945 insertions(+), 14 deletions(-) create mode 100644 packages/entitlements/bench/docker-compose.yml create mode 100644 packages/entitlements/bench/entitlements.bench.ts create mode 100644 packages/entitlements/bench/support/chargebee.ts create mode 100644 packages/entitlements/bench/support/config.ts create mode 100644 packages/entitlements/bench/support/network.ts create mode 100644 packages/entitlements/bench/support/postgres.ts create mode 100644 packages/entitlements/bench/support/stack.ts create mode 100644 packages/entitlements/bench/toxiproxy.json create mode 100644 packages/entitlements/bench/tsconfig.json diff --git a/packages/better-auth/src/version.ts b/packages/better-auth/src/version.ts index a3a25fb..e853866 100644 --- a/packages/better-auth/src/version.ts +++ b/packages/better-auth/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.2.0"; +export const VERSION = "1.3.0"; diff --git a/packages/entitlements/bench/docker-compose.yml b/packages/entitlements/bench/docker-compose.yml new file mode 100644 index 0000000..89070d0 --- /dev/null +++ b/packages/entitlements/bench/docker-compose.yml @@ -0,0 +1,41 @@ +name: entitlements-bench + +# Toxiproxy sits between the benchmarks and each service, so network latency +# is injected per service (REDIS_DELAY_MS, POSTGRES_DELAY_MS): +# +# vitest bench ──► toxiproxy :16379 ──► redis :6379 +# └─► toxiproxy :15432 ──► postgres :5432 +# └─► toxiproxy :8474 (control API) +services: + redis: + image: redis:8-alpine + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + retries: 30 + + postgres: + image: postgres:17-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: entitlements + healthcheck: + test: ["CMD", "pg_isready", "-h", "localhost", "-U", "postgres", "-d", "entitlements"] + interval: 2s + retries: 30 + + toxiproxy: + image: ghcr.io/shopify/toxiproxy:2.12.0 + command: ["-host", "0.0.0.0", "-config", "/config/toxiproxy.json"] + volumes: + - ./toxiproxy.json:/config/toxiproxy.json:ro + ports: + - "8474:8474" + - "16379:16379" + - "15432:15432" + depends_on: + redis: + condition: service_healthy + postgres: + condition: service_healthy diff --git a/packages/entitlements/bench/entitlements.bench.ts b/packages/entitlements/bench/entitlements.bench.ts new file mode 100644 index 0000000..0a1af16 --- /dev/null +++ b/packages/entitlements/bench/entitlements.bench.ts @@ -0,0 +1,165 @@ +import { afterAll, beforeAll, test } from "vitest"; +import type { + ChargebeeTarget, + EntitlementDetails, + EntitlementStatus, + SnapshotSource, +} from "../src/shared"; +import { + CHARGEBEE_DELAY_MS, + POSTGRES_DELAY_MS, + REDIS_DELAY_MS, +} from "./support/config"; +import { BenchStack } from "./support/stack"; + +const FEATURE_ID = "licensed-seats"; +const DEFAULT_SEATS = 0; + +// Each scenario owns a target, so interleaved iterations can't disturb each other. +const CACHE_HIT: ChargebeeTarget = { customerId: "cust_cache_hit" }; +const STORE_HIT: ChargebeeTarget = { customerId: "cust_store_hit" }; +const BLOCKING_MISS: ChargebeeTarget = { customerId: "cust_blocking_miss" }; +const BACKGROUND_MISS: ChargebeeTarget = { customerId: "cust_background_miss" }; +const FROM_API: ChargebeeTarget = { customerId: "cust_from_api" }; + +// `bench.compare` interleaves iterations, so a time budget would make fast +// scenarios wait on slow ones. Fixed counts keep runs bounded instead. +const STORED_RUN = { + time: 0, + iterations: 200, + warmupTime: 0, + warmupIterations: 20, +}; +const MISSING_RUN = { + time: 0, + iterations: 20, + warmupTime: 0, + warmupIterations: 3, +}; +const TIMEOUT_MS = 5 * 60 * 1000; + +const LATENCY = `redis +${REDIS_DELAY_MS}ms, postgres +${POSTGRES_DELAY_MS}ms, chargebee ${CHARGEBEE_DELAY_MS}ms`; + +let stack: BenchStack; + +beforeAll(async () => { + stack = await BenchStack.start(); +}); + +afterAll(async () => { + await stack?.close(); +}); + +/** Fails the run when a scenario took a different path than it claims to measure. */ +function expectPath( + details: EntitlementDetails, + status: EntitlementStatus, + source?: SnapshotSource, +): void { + if (details.status === status && details.source === source) { + return; + } + + throw new Error( + `Expected ${status}/${source}, got ${details.status}/${details.source}`, + ); +} + +test( + `snapshot stored (${LATENCY})`, + { timeout: TIMEOUT_MS }, + async ({ bench }) => { + await bench.compare( + // Redis GET. + bench( + "cache hit: Redis", + { beforeAll: () => stack.prime(CACHE_HIT) }, + async () => { + const details = await stack.blocking.getDetails( + FEATURE_ID, + DEFAULT_SEATS, + CACHE_HIT, + ); + expectPath(details, "granted", "cache"); + }, + ), + + // Redis GET miss, Postgres SELECT, Redis SET backfill. + bench( + "store hit: Redis miss, Postgres", + { + beforeAll: () => stack.prime(STORE_HIT), + beforeEach: () => stack.evictCache(STORE_HIT), + }, + async () => { + const details = await stack.blocking.getDetails( + FEATURE_ID, + DEFAULT_SEATS, + STORE_HIT, + ); + expectPath(details, "granted", "store"); + }, + ), + + STORED_RUN, + ); + }, +); + +test( + `snapshot missing (${LATENCY})`, + { timeout: TIMEOUT_MS }, + async ({ bench }) => { + let refreshed: Promise = Promise.resolve(); + + await bench.compare( + // Redis and Postgres miss, Chargebee, Postgres upsert, Redis SET. + bench( + "cold miss, blocking: Chargebee", + { beforeEach: () => stack.forget(BLOCKING_MISS) }, + async () => { + const details = await stack.blocking.getDetails( + FEATURE_ID, + DEFAULT_SEATS, + BLOCKING_MISS, + ); + expectPath(details, "granted", "api"); + }, + ), + + // Redis and Postgres miss, answers `pending`; Chargebee runs untimed. + bench( + "cold miss, background: pending", + { + beforeEach: async () => { + await stack.forget(BACKGROUND_MISS); + refreshed = stack.nextRefresh(); + }, + afterEach: () => refreshed, + }, + async () => { + const details = await stack.background.getDetails( + FEATURE_ID, + DEFAULT_SEATS, + BACKGROUND_MISS, + ); + expectPath(details, "pending"); + }, + ), + + // Chargebee, Postgres upsert, Redis DEL; skips both reads. + bench("getFromAPI: Chargebee", async () => { + const seats = await stack.blocking.getFromAPI( + FEATURE_ID, + DEFAULT_SEATS, + FROM_API, + ); + if (seats === DEFAULT_SEATS) { + throw new Error("getFromAPI fell back to the default"); + } + }), + + MISSING_RUN, + ); + }, +); diff --git a/packages/entitlements/bench/support/chargebee.ts b/packages/entitlements/bench/support/chargebee.ts new file mode 100644 index 0000000..d0625b7 --- /dev/null +++ b/packages/entitlements/bench/support/chargebee.ts @@ -0,0 +1,50 @@ +import Chargebee, { type HttpClientInterface } from "chargebee"; + +/** + * Chargebee SDK whose HTTP round trip is replaced by a canned one-page + * response after `delayMs`. Every customer is on the same plan. + */ +export function createMockChargebee(delayMs: number): Chargebee { + const httpClient: HttpClientInterface = { + async makeApiRequest() { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return Response.json({ + list: PLAN.map((row) => ({ customer_entitlement: row })), + }); + }, + }; + + return new Chargebee({ site: "bench", apiKey: "bench", httpClient }); +} + +const PLAN = [ + { + feature_id: "advanced-reports", + feature_name: "Advanced reports", + feature_type: "switch", + value: "true", + is_enabled: true, + }, + { + feature_id: "licensed-seats", + feature_name: "Licensed seats", + feature_type: "quantity", + feature_unit: "seat", + value: "25", + is_enabled: true, + }, + { + feature_id: "api-calls", + feature_name: "API calls per month", + feature_type: "range", + value: "unlimited", + is_enabled: true, + }, + { + feature_id: "support-tier", + feature_name: "Support tier", + feature_type: "custom", + value: "priority", + is_enabled: true, + }, +]; diff --git a/packages/entitlements/bench/support/config.ts b/packages/entitlements/bench/support/config.ts new file mode 100644 index 0000000..0a9c4bd --- /dev/null +++ b/packages/entitlements/bench/support/config.ts @@ -0,0 +1,45 @@ +/** + * Benchmark flags, read from the environment: + * + * pnpm bench:up + * REDIS_DELAY_MS=1 POSTGRES_DELAY_MS=3 CHARGEBEE_DELAY_MS=250 pnpm bench + * pnpm bench:down + * + * Redis and Postgres delays are added to every round trip by Toxiproxy. + * The Chargebee delay is the mock API's response time. + */ + +const DEFAULT_CHARGEBEE_DELAY_MS = 500; + +function readDelayMs(name: string, fallback = 0): number { + const raw = process.env[name]; + if (raw === undefined || raw === "") { + return fallback; + } + + const value = Number(raw); + if (!Number.isFinite(value) || value < 0) { + throw new Error(`${name} must be a non-negative number of milliseconds`); + } + + return value; +} + +export const REDIS_DELAY_MS = readDelayMs("REDIS_DELAY_MS"); +export const POSTGRES_DELAY_MS = readDelayMs("POSTGRES_DELAY_MS"); +export const CHARGEBEE_DELAY_MS = readDelayMs( + "CHARGEBEE_DELAY_MS", + DEFAULT_CHARGEBEE_DELAY_MS, +); + +// Toxiproxy ports from docker-compose.yml. +export const TOXIPROXY_URL = "http://localhost:8474"; +export const REDIS_URL = "redis://localhost:16379"; +export const POSTGRES_URL = + "postgres://postgres:postgres@localhost:15432/entitlements"; + +// Proxy names from toxiproxy.json. +export enum ProxyName { + Redis = "redis", + Postgres = "postgres", +} diff --git a/packages/entitlements/bench/support/network.ts b/packages/entitlements/bench/support/network.ts new file mode 100644 index 0000000..6f712da --- /dev/null +++ b/packages/entitlements/bench/support/network.ts @@ -0,0 +1,44 @@ +import { type ProxyName, TOXIPROXY_URL } from "./config"; + +const TOXIC_NAME = "latency"; +const HTTP_NOT_FOUND = 404; + +async function toxiproxy(path: string, init?: RequestInit): Promise { + const response = await fetch(`${TOXIPROXY_URL}${path}`, init); + if (response.ok || response.status === HTTP_NOT_FOUND) { + return response; + } + + throw new Error( + `Toxiproxy ${init?.method ?? "GET"} ${path}: ${response.status} ${await response.text()}`, + ); +} + +/** + * Delays every response from `proxy` by `delayMs`, so each round trip pays + * it once, like a cloud network hop. 0 removes the delay. + */ +export async function setLatency( + proxy: ProxyName, + delayMs: number, +): Promise { + await toxiproxy(`/proxies/${proxy}/toxics/${TOXIC_NAME}`, { + method: "DELETE", + }); + + if (delayMs === 0) { + return; + } + + await toxiproxy(`/proxies/${proxy}/toxics`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: TOXIC_NAME, + type: "latency", + stream: "downstream", + toxicity: 1, + attributes: { latency: delayMs, jitter: 0 }, + }), + }); +} diff --git a/packages/entitlements/bench/support/postgres.ts b/packages/entitlements/bench/support/postgres.ts new file mode 100644 index 0000000..bbce554 --- /dev/null +++ b/packages/entitlements/bench/support/postgres.ts @@ -0,0 +1,51 @@ +import type { Pool } from "pg"; +import { + type EntitlementsStorage, + parseSerializedEntitlementsSnapshot, + serializeEntitlementsSnapshot, +} from "../../src/cache"; + +/** + * Durable snapshot store over Postgres. Rows never expire: the client serves + * an expired snapshot while it refreshes, so `ttlMs` is ignored. + */ +export function createPostgresStore(pool: Pool): EntitlementsStorage { + return { + async get(key) { + const { rows } = await pool.query<{ document: string }>( + "select document from entitlements_snapshot where key = $1", + [key], + ); + const row = rows[0]; + if (!row) { + return undefined; + } + + return parseSerializedEntitlementsSnapshot(row.document); + }, + async set(key, value) { + await pool.query( + `insert into entitlements_snapshot (key, document) values ($1, $2) + on conflict (key) do update set document = excluded.document`, + [key, serializeEntitlementsSnapshot(value)], + ); + }, + async delete(key) { + await pool.query("delete from entitlements_snapshot where key = $1", [ + key, + ]); + }, + async clear() { + await pool.query("truncate entitlements_snapshot"); + }, + }; +} + +export async function migrate(pool: Pool): Promise { + await pool.query( + `create table if not exists entitlements_snapshot ( + key text primary key, + document text not null + )`, + ); +} diff --git a/packages/entitlements/bench/support/stack.ts b/packages/entitlements/bench/support/stack.ts new file mode 100644 index 0000000..3df6e8a --- /dev/null +++ b/packages/entitlements/bench/support/stack.ts @@ -0,0 +1,121 @@ +import Redis from "ioredis"; +import { Pool } from "pg"; +import { + createEntitlementsCacheKey, + createRedisEntitlementsCache, + type EntitlementsStorage, +} from "../../src/cache"; +import { + ChargebeeEntitlements, + type ChargebeeEntitlementsOptions, +} from "../../src/server"; +import type { ChargebeeTarget } from "../../src/shared"; +import { createMockChargebee } from "./chargebee"; +import { + CHARGEBEE_DELAY_MS, + POSTGRES_DELAY_MS, + POSTGRES_URL, + ProxyName, + REDIS_DELAY_MS, + REDIS_URL, +} from "./config"; +import { setLatency } from "./network"; +import { createPostgresStore, migrate } from "./postgres"; + +// Outlasts a run, so stored snapshots never trigger background refreshes. +const SNAPSHOT_TTL_MS = 60 * 60 * 1000; + +/** + * Production-shaped setup: Redis cache, Postgres store, mock Chargebee. + * + * ChargebeeEntitlements ──► Redis ──► Postgres ──► Chargebee (mock) + * + * `blocking` waits for Chargebee on a miss; `background` answers `pending`. + */ +export class BenchStack { + readonly blocking: ChargebeeEntitlements; + readonly background: ChargebeeEntitlements; + private readonly cache: EntitlementsStorage; + private readonly refreshWaiters: Array<() => void> = []; + + private constructor( + private readonly redis: Redis, + private readonly pool: Pool, + ) { + this.cache = createRedisEntitlementsCache(redis); + + const options: ChargebeeEntitlementsOptions = { + chargebeeClient: createMockChargebee(CHARGEBEE_DELAY_MS), + cache: this.cache, + durableStore: createPostgresStore(pool), + snapshotTtlMs: SNAPSHOT_TTL_MS, + onError: (error, info) => { + console.error(`${info.operation} failed`, error); + }, + }; + + this.blocking = new ChargebeeEntitlements({ + ...options, + refreshOnMiss: "blocking", + }); + + // Background refreshes finish after the call returns; waiters learn when. + this.background = new ChargebeeEntitlements({ + ...options, + refreshOnMiss: "background", + onSnapshotRefreshed: () => this.settleRefresh(), + onError: (error, info) => { + options.onError?.(error, info); + this.settleRefresh(); + }, + }); + } + + static async start(): Promise { + await setLatency(ProxyName.Redis, REDIS_DELAY_MS); + await setLatency(ProxyName.Postgres, POSTGRES_DELAY_MS); + + const redis = new Redis(REDIS_URL); + const pool = new Pool({ connectionString: POSTGRES_URL }); + + // Start empty, so leftovers from earlier runs can't change the paths taken. + await redis.flushdb(); + await migrate(pool); + await createPostgresStore(pool).clear?.(); + + return new BenchStack(redis, pool); + } + + /** Loads the snapshot into Redis and Postgres. */ + async prime(target: ChargebeeTarget): Promise { + await this.blocking.getSnapshot(target); + } + + /** Drops the Redis copy only, so the next read falls through to Postgres. */ + async evictCache(target: ChargebeeTarget): Promise { + await this.cache.delete(createEntitlementsCacheKey(target)); + } + + /** Drops the snapshot from Redis and Postgres. */ + async forget(target: ChargebeeTarget): Promise { + await this.blocking.deleteSnapshot(target); + } + + /** Resolves when the next background refresh lands or fails. */ + nextRefresh(): Promise { + return new Promise((resolve) => this.refreshWaiters.push(resolve)); + } + + async close(): Promise { + await this.blocking.close(); + await this.background.close(); + await this.redis.quit(); + await this.pool.end(); + } + + private settleRefresh(): void { + for (const resolve of this.refreshWaiters.splice(0)) { + resolve(); + } + } +} diff --git a/packages/entitlements/bench/toxiproxy.json b/packages/entitlements/bench/toxiproxy.json new file mode 100644 index 0000000..058661e --- /dev/null +++ b/packages/entitlements/bench/toxiproxy.json @@ -0,0 +1,14 @@ +[ + { + "name": "redis", + "listen": "0.0.0.0:16379", + "upstream": "redis:6379", + "enabled": true + }, + { + "name": "postgres", + "listen": "0.0.0.0:15432", + "upstream": "postgres:5432", + "enabled": true + } +] diff --git a/packages/entitlements/bench/tsconfig.json b/packages/entitlements/bench/tsconfig.json new file mode 100644 index 0000000..f2722d6 --- /dev/null +++ b/packages/entitlements/bench/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "composite": false, + "incremental": false, + "declaration": false, + "declarationDir": null, + "emitDeclarationOnly": false, + "noEmit": true + }, + "include": ["./**/*"] +} diff --git a/packages/entitlements/package.json b/packages/entitlements/package.json index 5cebf31..88f935e 100644 --- a/packages/entitlements/package.json +++ b/packages/entitlements/package.json @@ -81,9 +81,12 @@ "scripts": { "build": "tsdown", "dev": "tsdown --watch", - "typecheck": "tsc --project tsconfig.json", - "test": "vitest run", - "test:watch": "vitest", + "typecheck": "tsc --project tsconfig.json && tsc --project bench/tsconfig.json", + "test": "vitest run", + "test:watch": "vitest", + "bench": "vitest bench --run --reporter=default", + "bench:up": "docker compose -f bench/docker-compose.yml up -d --wait", + "bench:down": "docker compose -f bench/docker-compose.yml down -v", "coverage": "vitest run --coverage", "lint:package": "publint run --strict", "lint:types": "attw --profile esm-only --pack ." @@ -97,14 +100,16 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", "@types/node": "22.15.2", - "@vitest/coverage-v8": "^4.0.18", + "@types/pg": "^8.23.1", + "@vitest/coverage-v8": "^5.0.5", "chargebee": "^3.30.0", "ioredis": "^6.0.0", "next": "^16.3.0", + "pg": "^8.23.1", "publint": "^0.3.23", "tsdown": "^0.20.3", "typescript": "^5.9.2", - "vitest": "^4.0.18" + "vitest": "^5.0.5" }, "peerDependencies": { "chargebee": "^3.30.0", diff --git a/packages/entitlements/vitest.config.ts b/packages/entitlements/vitest.config.ts index e91c446..a42cb69 100644 --- a/packages/entitlements/vitest.config.ts +++ b/packages/entitlements/vitest.config.ts @@ -4,6 +4,9 @@ export default defineConfig({ test: { clearMocks: true, globals: true, + benchmark: { + include: ["bench/**/*.bench.ts"], + }, coverage: { provider: "v8", reporter: ["text", "json", "html"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69db620..4991f97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,7 +48,7 @@ importers: version: 4.0.18(vitest@4.0.18(@types/node@22.15.2)(jiti@2.6.1)) better-auth: specifier: ^1.5.3 - version: 1.5.3(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)))(mongodb@7.1.0)(mysql2@3.15.3)(next@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)) + version: 1.5.3(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(@types/pg@8.23.1)(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)))(mongodb@7.1.0)(mysql2@3.15.3)(next@16.3.0(@types/node@22.15.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.23.1)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@4.0.18(@types/node@22.15.2)(jiti@2.6.1)) better-call: specifier: ^1.1.8 version: 1.1.8(zod@4.3.6) @@ -114,9 +114,12 @@ importers: '@types/node': specifier: 22.15.2 version: 22.15.2 + '@types/pg': + specifier: ^8.23.1 + version: 8.23.1 '@vitest/coverage-v8': - specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@22.15.2)(jiti@2.6.1)) + specifier: ^5.0.5 + version: 5.0.5(vitest@5.0.5) chargebee: specifier: ^3.30.0 version: 3.30.0 @@ -126,6 +129,9 @@ importers: next: specifier: ^16.3.0 version: 16.3.0(@types/node@22.15.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + pg: + specifier: ^8.23.1 + version: 8.23.1 publint: specifier: ^0.3.23 version: 0.3.23 @@ -136,8 +142,8 @@ importers: 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) + specifier: ^5.0.5 + version: 5.0.5(@types/node@22.15.2)(@vitest/coverage-v8@5.0.5)(vite@7.3.1(@types/node@22.15.2)(jiti@2.6.1)) packages/express: dependencies: @@ -227,6 +233,10 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.0-rc.1': resolution: {integrity: sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -235,6 +245,10 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.0-rc.1': resolution: {integrity: sha512-I4YnARytXC2RzkLNVnf5qFNFMzp679qZpmtw/V3Jt2uGnWiIxyJtaukjG7R8pSx8nG2NamICpGfljQsogj+FbQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -244,6 +258,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/parser@8.0.0-rc.1': resolution: {integrity: sha512-6HyyU5l1yK/7h9Ki52i5h6mDAx4qJdiLQO4FdCyJNoB/gy3T3GGJdhQzzbZgvgZCugYBvwtQiWRt94QKedHnkA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -253,6 +272,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@babel/types@8.0.0-rc.1': resolution: {integrity: sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -861,6 +884,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -1356,6 +1382,9 @@ packages: '@types/node@22.15.2': resolution: {integrity: sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A==} + '@types/pg@8.23.1': + resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} + '@types/qs@6.9.18': resolution: {integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==} @@ -1389,9 +1418,26 @@ packages: '@vitest/browser': optional: true + '@vitest/coverage-v8@5.0.5': + resolution: {integrity: sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==} + peerDependencies: + '@vitest/browser': 5.0.5 + vitest: 5.0.5 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/istanbul-lib-coverage@1.0.2': + resolution: {integrity: sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-report@1.0.2': + resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==} + engines: {node: '>=22'} + '@vitest/mocker@4.0.18': resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} peerDependencies: @@ -1403,6 +1449,17 @@ packages: vite: optional: true + '@vitest/mocker@5.0.5': + resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} + peerDependencies: + msw: ^2.4.9 || ^3.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@4.0.18': resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} @@ -1415,6 +1472,9 @@ packages: '@vitest/spy@4.0.18': resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@5.0.5': + resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} @@ -1460,6 +1520,9 @@ packages: ast-v8-to-istanbul@0.3.11: resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + ast-v8-to-istanbul@1.0.7: + resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} + aws-ssl-profiles@1.1.2: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} @@ -1618,6 +1681,10 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1936,6 +2003,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1967,6 +2037,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + express@5.1.0: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} @@ -2190,9 +2264,15 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + magicast@0.5.2: resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -2385,6 +2465,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -2425,6 +2509,40 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + pg-cloudflare@1.4.1: + resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==} + + pg-connection-string@2.14.1: + resolution: {integrity: sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.16.1: + resolution: {integrity: sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.23.1: + resolution: {integrity: sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2432,6 +2550,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} @@ -2443,6 +2565,22 @@ packages: resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + postgres@3.4.7: resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} engines: {node: '>=12'} @@ -2700,6 +2838,10 @@ packages: sparse-bitfield@3.0.3: resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sqlstring@2.3.3: resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} engines: {node: '>= 0.6'} @@ -2717,6 +2859,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -2774,6 +2919,10 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinybench@6.2.2: + resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} + engines: {node: '>=20.0.0'} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -2782,14 +2931,26 @@ packages: resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} engines: {node: '>=18'} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinyrainbow@3.0.3: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -2964,6 +3125,47 @@ packages: jsdom: optional: true + vitest@5.0.5: + resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.5 + '@vitest/browser-preview': 5.0.5 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.5 + '@vitest/coverage-v8': 5.0.5 + '@vitest/ui': 5.0.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -2982,6 +3184,11 @@ packages: engines: {node: '>=8'} hasBin: true + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -2989,6 +3196,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3052,16 +3263,24 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@8.0.0-rc.1': {} '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-identifier@8.0.0-rc.1': {} '@babel/parser@7.29.0': dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + '@babel/parser@8.0.0-rc.1': dependencies: '@babel/types': 8.0.0-rc.1 @@ -3071,6 +3290,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@8.0.0-rc.1': dependencies: '@babel/helper-string-parser': 8.0.0-rc.1 @@ -3493,6 +3717,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -3870,6 +4096,12 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pg@8.23.1': + dependencies: + '@types/node': 22.15.2 + pg-protocol: 1.16.1 + pg-types: 2.2.0 + '@types/qs@6.9.18': {} '@types/range-parser@1.2.7': {} @@ -3914,6 +4146,18 @@ snapshots: tinyrainbow: 3.0.3 vitest: 4.0.18(@types/node@22.15.2)(jiti@2.6.1) + '@vitest/coverage-v8@5.0.5(vitest@5.0.5)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/istanbul-lib-coverage': 1.0.2 + '@vitest/istanbul-lib-report': 1.0.2 + ast-v8-to-istanbul: 1.0.7 + magicast: 0.5.5 + obug: 3.0.0 + std-env: 4.3.0 + tinyrainbow: 3.2.0 + vitest: 5.0.5(@types/node@22.15.2)(@vitest/coverage-v8@5.0.5)(vite@7.3.1(@types/node@22.15.2)(jiti@2.6.1)) + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -3923,6 +4167,12 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 + '@vitest/istanbul-lib-coverage@1.0.2': {} + + '@vitest/istanbul-lib-report@1.0.2': + dependencies: + '@vitest/istanbul-lib-coverage': 1.0.2 + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.15.2)(jiti@2.6.1))': dependencies: '@vitest/spy': 4.0.18 @@ -3931,6 +4181,15 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@22.15.2)(jiti@2.6.1) + '@vitest/mocker@5.0.5(vite@7.3.1(@types/node@22.15.2)(jiti@2.6.1))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.5 + estree-walker: 3.0.3 + magic-string: 1.4.3 + optionalDependencies: + vite: 7.3.1(@types/node@22.15.2)(jiti@2.6.1) + '@vitest/pretty-format@4.0.18': dependencies: tinyrainbow: 3.0.3 @@ -3948,6 +4207,8 @@ snapshots: '@vitest/spy@4.0.18': {} + '@vitest/spy@5.0.5': {} + '@vitest/utils@4.0.18': dependencies: '@vitest/pretty-format': 4.0.18 @@ -3990,6 +4251,12 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + ast-v8-to-istanbul@1.0.7: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + aws-ssl-profiles@1.1.2: optional: true @@ -3997,7 +4264,7 @@ snapshots: 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)): + better-auth@1.5.3(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(@types/pg@8.23.1)(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)))(mongodb@7.1.0)(mysql2@3.15.3)(next@16.3.0(@types/node@22.15.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.23.1)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@4.0.18(@types/node@22.15.2)(jiti@2.6.1)): dependencies: '@better-auth/core': 1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/kysely-adapter': 1.5.3(@better-auth/core@1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11) @@ -4016,10 +4283,11 @@ snapshots: optionalDependencies: '@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2) better-sqlite3: 12.6.2 - drizzle-orm: 0.45.1(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)) + drizzle-orm: 0.45.1(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(@types/pg@8.23.1)(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)) mongodb: 7.1.0 mysql2: 3.15.3 next: 16.3.0(@types/node@22.15.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + pg: 8.23.1 prisma: 7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -4122,6 +4390,8 @@ snapshots: chai@6.2.2: {} + chai@6.3.0: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -4275,13 +4545,15 @@ snapshots: dotenv@16.6.1: optional: true - drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)): + drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2))(@types/pg@8.23.1)(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)): optionalDependencies: '@electric-sql/pglite': 0.3.15 '@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2))(typescript@5.9.2) + '@types/pg': 8.23.1 better-sqlite3: 12.6.2 kysely: 0.28.11 mysql2: 3.15.3 + pg: 8.23.1 postgres: 3.4.7 prisma: 7.4.2(@types/react@19.2.14)(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2) optional: true @@ -4327,6 +4599,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -4374,6 +4648,8 @@ snapshots: expect-type@1.3.0: {} + expect-type@1.4.0: {} + express@5.1.0(supports-color@7.2.0): dependencies: accepts: 2.0.0 @@ -4418,6 +4694,10 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + fflate@0.8.3: {} file-uri-to-path@1.0.0: {} @@ -4618,12 +4898,22 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@1.4.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + magicast@0.5.2: dependencies: '@babel/parser': 7.29.0 '@babel/types': 7.29.0 source-map-js: 1.2.1 + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + make-dir@4.0.0: dependencies: semver: 7.8.5 @@ -4792,6 +5082,8 @@ snapshots: obug@2.1.1: {} + obug@3.0.0: {} + ohash@2.0.11: optional: true @@ -4825,10 +5117,47 @@ snapshots: perfect-debounce@1.0.0: optional: true + pg-cloudflare@1.4.1: + optional: true + + pg-connection-string@2.14.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.23.1): + dependencies: + pg: 8.23.1 + + pg-protocol@1.16.1: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.23.1: + dependencies: + pg-connection-string: 2.14.1 + pg-pool: 3.14.0(pg@8.23.1) + pg-protocol: 1.16.1 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.1 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@4.0.3: {} + picomatch@4.0.7: {} + pkg-types@2.3.0: dependencies: confbox: 0.2.4 @@ -4848,6 +5177,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + postgres@3.4.7: optional: true @@ -5240,6 +5579,8 @@ snapshots: memory-pager: 1.5.0 optional: true + split2@4.2.0: {} + sqlstring@2.3.3: optional: true @@ -5251,6 +5592,8 @@ snapshots: std-env@3.10.0: {} + std-env@4.3.0: {} + streamsearch@1.1.0: {} string-width@4.2.3: @@ -5308,17 +5651,28 @@ snapshots: tinybench@2.9.0: {} + tinybench@6.2.2: {} + tinyexec@1.0.2: {} tinyexec@1.3.0: {} + tinyexec@1.3.1: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + tinyrainbow@3.0.3: {} + tinyrainbow@3.2.0: {} + toidentifier@1.0.1: {} tr46@5.1.1: @@ -5449,6 +5803,28 @@ snapshots: - tsx - yaml + vitest@5.0.5(@types/node@22.15.2)(@vitest/coverage-v8@5.0.5)(vite@7.3.1(@types/node@22.15.2)(jiti@2.6.1)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.5(vite@7.3.1(@types/node@22.15.2)(jiti@2.6.1)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 3.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.2 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 7.3.1(@types/node@22.15.2)(jiti@2.6.1) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 22.15.2 + '@vitest/coverage-v8': 5.0.5(vitest@5.0.5) + transitivePeerDependencies: + - msw + webidl-conversions@7.0.0: optional: true @@ -5468,6 +5844,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + why-is-node-running@3.2.1: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -5476,6 +5854,8 @@ snapshots: wrappy@1.0.2: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yargs-parser@20.2.9: {}