From 2be591b160ef2c67962b340d4b113fba2eabd786 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Tue, 11 Aug 2026 14:33:33 +1000 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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.