UNPKG

better-auth-feature-flags

Version:

Ship features safely with feature flags, A/B testing, and progressive rollouts - Better Auth plugin for modern release management

1,534 lines (1,529 loc) 50.4 kB
import { __name } from "./chunk-SHUYVCID.js"; // src/client/cache.ts var FlagCache = class { constructor(options = {}) { this.cache = /* @__PURE__ */ new Map(); this.storage = null; this.accessOrder = []; this.keyPrefix = options.keyPrefix || "ff_"; this.version = options.version || "1"; this.defaultTTL = options.ttl || 6e4; this.maxEntries = options.maxEntries || 100; this.include = options.include; this.exclude = options.exclude; this.keyPrefix = `${this.keyPrefix}${this.version}_`; if (typeof globalThis !== "undefined" && options.storage !== "memory") { try { const storage = options.storage === "sessionStorage" ? globalThis.sessionStorage : globalThis.localStorage; if (storage) { const testKey = `${this.keyPrefix}_test`; storage.setItem(testKey, "test"); storage.removeItem(testKey); this.storage = storage; this.loadFromStorage(); this.cleanOldVersions(); } } catch { this.storage = null; } } } static { __name(this, "FlagCache"); } shouldCache(key) { if (this.exclude?.includes(key)) return false; if (this.include && !this.include.includes(key)) return false; return true; } cleanOldVersions() { if (!this.storage) return; for (let i = 0; i < this.storage.length; i++) { const key = this.storage.key(i); if (key.startsWith("ff_") && !key.startsWith(this.keyPrefix)) { this.storage.removeItem(key); i--; } } } loadFromStorage() { if (!this.storage) return; for (let i = 0; i < this.storage.length; i++) { const key = this.storage.key(i); if (key.startsWith(this.keyPrefix)) { try { const data = JSON.parse(this.storage.getItem(key) || ""); if (data && typeof data === "object" && "value" in data) { const flagKey = key.slice(this.keyPrefix.length); if (!data.sessionId || data.sessionId === this.currentSessionId) { this.cache.set(flagKey, data); this.accessOrder.push(flagKey); } } } catch { this.storage.removeItem(key); } } } } evictLRU() { if (this.cache.size >= this.maxEntries && this.accessOrder.length > 0) { const lruKey = this.accessOrder.shift(); if (lruKey) { this.cache.delete(lruKey); if (this.storage) this.storage.removeItem(`${this.keyPrefix}${lruKey}`); } } } updateAccessOrder(key) { const index = this.accessOrder.indexOf(key); if (index > -1) this.accessOrder.splice(index, 1); this.accessOrder.push(key); } safeStorageWrite(key, value) { if (!this.storage) return false; try { this.storage.setItem(key, value); return true; } catch (e) { if (e?.name === "QuotaExceededError" || e?.code === 22) { this.clearOldestStorageEntries(5); try { this.storage.setItem(key, value); return true; } catch { console.warn( "[feature-flags] Storage quota exceeded, using memory only" ); return false; } } return false; } } clearOldestStorageEntries(count) { if (!this.storage) return; const toRemove = this.accessOrder.slice(0, count); for (const key of toRemove) this.storage.removeItem(`${this.keyPrefix}${key}`); } get(key) { if (!this.shouldCache(key)) return void 0; const entry = this.cache.get(key); if (!entry) return void 0; if (Date.now() - entry.timestamp > entry.ttl) { this.delete(key); return void 0; } if (entry.sessionId && entry.sessionId !== this.currentSessionId) { this.delete(key); return void 0; } this.updateAccessOrder(key); return entry.value; } set(key, value, ttl) { if (!this.shouldCache(key)) return; this.evictLRU(); const entry = { value, timestamp: Date.now(), ttl: ttl || this.defaultTTL, sessionId: this.currentSessionId }; this.cache.set(key, entry); this.updateAccessOrder(key); if (this.storage) { const storageKey = `${this.keyPrefix}${key}`; this.safeStorageWrite(storageKey, JSON.stringify(entry)); } } delete(key) { this.cache.delete(key); const index = this.accessOrder.indexOf(key); if (index > -1) this.accessOrder.splice(index, 1); if (this.storage) this.storage.removeItem(`${this.keyPrefix}${key}`); } clear() { this.cache.clear(); this.accessOrder = []; if (!this.storage) return; for (let i = 0; i < this.storage.length; i++) { const key = this.storage.key(i); if (key.startsWith(this.keyPrefix)) { this.storage.removeItem(key); i--; } } } /** Batch get to reduce repeated lookups */ getMany(keys) { const results = /* @__PURE__ */ new Map(); for (const key of keys) { const value = this.get(key); if (value !== void 0) results.set(key, value); } return results; } /** Batch set for efficient bulk operations */ setMany(entries, ttl) { for (const [key, value] of Object.entries(entries)) this.set(key, value, ttl); } /** Clear cache on session change for security */ invalidateOnSessionChange(newSessionId) { const sessionChanged = newSessionId !== this.currentSessionId; this.currentSessionId = newSessionId; if (sessionChanged) this.clear(); } getCurrentSessionId() { return this.currentSessionId; } }; // src/context-sanitizer.ts var DEFAULT_ALLOWED_FIELDS = /* @__PURE__ */ new Set([ // User attributes "userId", "organizationId", "teamId", "role", "plan", "subscription", // Device/environment "device", "browser", "os", "platform", "version", "locale", "timezone", // Application state "page", "route", "feature", "experiment", // Safe business attributes "country", "region", "environment", "buildVersion" ]); var FORBIDDEN_FIELDS = /* @__PURE__ */ new Set([ "password", "token", "apiKey", "secret", "creditCard", "ssn", "socialSecurityNumber", "driverLicense", "passport", "bankAccount", "privateKey", "sessionToken", "refreshToken", "accessToken", "authToken" ]); var SENSITIVE_PATTERNS = [ /password/i, /secret/i, /token/i, /key/i, /credit/i, /ssn/i, /bank/i, /private/i, /auth/i ]; var ContextSanitizer = class { static { __name(this, "ContextSanitizer"); } constructor(options = {}) { this.allowedFields = options.allowedFields || DEFAULT_ALLOWED_FIELDS; this.maxSizeForUrl = options.maxSizeForUrl || 2048; this.maxSizeForBody = options.maxSizeForBody || 10240; this.strict = options.strict ?? true; this.warnOnDrop = options.warnOnDrop ?? process.env.NODE_ENV === "development"; } /** * Sanitizes context for URL params with strict size limits. * @returns JSON string under 2KB or undefined if too large */ sanitizeForUrl(context) { const sanitized = this.sanitize(context); if (Object.keys(sanitized).length === 0) { return void 0; } const serialized = JSON.stringify(sanitized); if (serialized.length > this.maxSizeForUrl) { const essential = this.extractEssentialFields(sanitized); const essentialSerialized = JSON.stringify(essential); if (essentialSerialized.length > this.maxSizeForUrl) { if (this.warnOnDrop) { console.warn( `[feature-flags] Context too large for URL (${serialized.length} bytes). Maximum allowed: ${this.maxSizeForUrl} bytes. Consider using fewer fields.` ); } return void 0; } return essentialSerialized; } return serialized; } /** * Sanitizes context for POST body with larger size allowance. * @returns Sanitized object under 10KB or progressively reduced object */ sanitizeForBody(context) { const sanitized = this.sanitize(context); if (Object.keys(sanitized).length === 0) { return void 0; } const serialized = JSON.stringify(sanitized); if (serialized.length > this.maxSizeForBody) { if (this.warnOnDrop) { console.warn( `[feature-flags] Context too large for request (${serialized.length} bytes). Maximum allowed: ${this.maxSizeForBody} bytes. Some fields will be dropped.` ); } return this.reduceToSize(sanitized, this.maxSizeForBody); } return sanitized; } // Core sanitization: PII filtering + type normalization sanitize(context) { const result = {}; const droppedFields = []; for (const [key, value] of Object.entries(context)) { if (FORBIDDEN_FIELDS.has(key)) { droppedFields.push(`${key} (forbidden)`); continue; } if (SENSITIVE_PATTERNS.some((pattern) => pattern.test(key))) { droppedFields.push(`${key} (sensitive pattern)`); continue; } if (this.strict && !this.allowedFields.has(key)) { droppedFields.push(`${key} (not in allowlist)`); continue; } if (value === null || value === void 0) { result[key] = value; } else if (typeof value === "object" && !Array.isArray(value)) { const sanitizedNested = this.sanitize(value); if (Object.keys(sanitizedNested).length > 0) { result[key] = sanitizedNested; } } else if (Array.isArray(value)) { result[key] = value.slice(0, 10); } else if (typeof value === "string") { result[key] = value.length > 200 ? value.substring(0, 200) + "..." : value; } else if (typeof value !== "function" && typeof value !== "symbol") { result[key] = value; } } if (this.warnOnDrop && droppedFields.length > 0) { console.warn( `[feature-flags] Dropped context fields for security: ${droppedFields.join(", ")}` ); } return result; } // Extracts essential fields for minimal context extractEssentialFields(context) { const essentialKeys = [ "userId", "organizationId", "role", "plan", "device", "environment" ]; const result = {}; for (const key of essentialKeys) { if (key in context) { result[key] = context[key]; } } return result; } // Progressively removes fields until size acceptable reduceToSize(context, maxSize) { const entries = Object.entries(context); let result = { ...context }; entries.sort((a, b) => a[0].length - b[0].length); for (let i = entries.length - 1; i >= 0; i--) { const serialized = JSON.stringify(result); if (serialized.length <= maxSize) { break; } const tuple = entries[i]; if (!tuple) continue; const key = tuple[0]; delete result[key]; } return result; } /** * Pre-flight validation to detect potential PII before sanitization. * @returns Array of warning messages for sensitive fields */ static validate(context) { const warnings = []; const checkObject = /* @__PURE__ */ __name((obj, path = "") => { for (const [key, value] of Object.entries(obj)) { const fullPath = path ? `${path}.${key}` : key; if (FORBIDDEN_FIELDS.has(key)) { warnings.push( `Forbidden field "${fullPath}" detected - will be removed` ); } if (SENSITIVE_PATTERNS.some((pattern) => pattern.test(key))) { warnings.push( `Potentially sensitive field "${fullPath}" detected - will be removed` ); } if (value && typeof value === "object") { checkObject(value, fullPath); } } }, "checkObject"); checkObject(context); return warnings; } }; var defaultSanitizer = new ContextSanitizer(); // src/override-manager.ts var SecureOverrideManager = class { constructor(config = {}) { this.overrides = /* @__PURE__ */ new Map(); this.ttl = config.ttl ?? 36e5; this.allowInProduction = config.allowInProduction ?? false; this.persist = config.persist ?? false; this.storageKey = `${config.keyPrefix ?? "feature-flags"}-overrides`; this.environment = config.environment ?? this.detectEnvironment(); if (this.persist && this.isOverrideAllowed()) { this.loadFromStorage(); } this.startCleanupTimer(); } static { __name(this, "SecureOverrideManager"); } /** * Multi-indicator environment detection. * @algorithm NODE_ENV → hostname → build flags * @see https://12factor.net/config */ detectEnvironment() { if (typeof process !== "undefined" && process.env?.NODE_ENV) { return process.env.NODE_ENV; } if (typeof window !== "undefined") { const hostname = window.location.hostname; if (hostname !== "localhost" && !hostname.startsWith("127.") && !hostname.startsWith("192.168.") && !hostname.includes(".local") && !hostname.includes("staging")) { return "production"; } if (typeof import.meta?.env?.MODE !== "undefined") { return import.meta.env.MODE; } } return "development"; } /** Guards production override access */ isOverrideAllowed() { if (this.environment === "production" && !this.allowInProduction) { return false; } return true; } /** * Sets flag override with environment protection. * @security Blocks production unless allowInProduction=true * @returns true if set, false if blocked */ set(flag, value) { if (!this.isOverrideAllowed()) { console.warn( "[feature-flags] Overrides are disabled in production. Set allowInProduction: true to enable (not recommended)." ); return false; } const override = { value, expires: Date.now() + this.ttl, environment: this.environment }; this.overrides.set(flag, override); if (this.persist) { this.saveToStorage(); } if (this.environment === "development") { console.debug( `[feature-flags] Override set: ${flag} = ${JSON.stringify(value)}, expires in ${this.ttl}ms` ); } return true; } /** Gets override value, auto-expires stale entries */ get(flag) { if (!this.isOverrideAllowed()) { return void 0; } const override = this.overrides.get(flag); if (!override) { return void 0; } if (Date.now() > override.expires) { this.overrides.delete(flag); if (this.persist) { this.saveToStorage(); } return void 0; } if (override.environment !== this.environment) { console.warn( `[feature-flags] Override "${flag}" was set in ${override.environment} but current is ${this.environment}` ); } return override.value; } /** Checks override existence without returning value */ has(flag) { return this.get(flag) !== void 0; } /** Clears specific override and syncs storage */ delete(flag) { this.overrides.delete(flag); if (this.persist) { this.saveToStorage(); } } /** Clears all overrides and storage */ clear() { this.overrides.clear(); if (this.persist) { this.clearStorage(); } } /** Returns all non-expired overrides for debugging */ getAll() { if (!this.isOverrideAllowed()) { return {}; } const result = {}; for (const [key, override] of this.overrides) { if (Date.now() <= override.expires) { result[key] = override.value; } } return result; } /** Loads persisted overrides, skips expired entries */ loadFromStorage() { if (typeof globalThis.localStorage === "undefined") { return; } try { const stored = localStorage.getItem(this.storageKey); if (!stored) { return; } const data = JSON.parse(stored); const now = Date.now(); for (const [key, override] of Object.entries(data)) { if (override.expires > now) { this.overrides.set(key, override); } } } catch (error) { console.warn( "[feature-flags] Failed to load overrides from storage:", error ); } } /** Persists current overrides to localStorage */ saveToStorage() { if (typeof globalThis.localStorage === "undefined") { return; } try { const data = {}; for (const [key, override] of this.overrides) { data[key] = override; } localStorage.setItem(this.storageKey, JSON.stringify(data)); } catch (error) { console.warn( "[feature-flags] Failed to save overrides to storage:", error ); } } /** Removes persisted overrides from storage */ clearStorage() { if (typeof globalThis.localStorage === "undefined") { return; } try { localStorage.removeItem(this.storageKey); } catch (error) { console.warn( "[feature-flags] Failed to clear overrides from storage:", error ); } } /** Starts 1-minute cleanup timer for expired overrides */ startCleanupTimer() { this.cleanupTimer = setInterval(() => { this.cleanupExpired(); }, 6e4); if (typeof process !== "undefined" && this.cleanupTimer.unref) { this.cleanupTimer.unref(); } } /** Removes expired overrides and syncs storage */ cleanupExpired() { const now = Date.now(); let hasChanges = false; for (const [key, override] of this.overrides) { if (override.expires <= now) { this.overrides.delete(key); hasChanges = true; } } if (hasChanges && this.persist) { this.saveToStorage(); } } /** Cleanup timer and resources */ dispose() { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); } } }; // src/polling.ts var SmartPoller = class { constructor(baseInterval, task, onError) { this.baseInterval = baseInterval; this.task = task; this.onError = onError; this.timer = null; this.consecutiveErrors = 0; this.maxRetries = 5; this.currentInterval = baseInterval; this.maxInterval = Math.min(baseInterval * 10, 3e5); } static { __name(this, "SmartPoller"); } start() { this.stop(); this.scheduleNext(); } stop() { if (this.timer) { clearTimeout(this.timer); this.timer = null; } } scheduleNext() { const jitter = Math.random() * this.currentInterval * 0.25; const delay = this.currentInterval + jitter; this.timer = setTimeout(() => { this.execute(); }, delay); } async execute() { try { await this.task(); this.consecutiveErrors = 0; this.currentInterval = this.baseInterval; } catch (error) { this.consecutiveErrors++; if (this.consecutiveErrors <= this.maxRetries) { this.currentInterval = Math.min( this.baseInterval * Math.pow(2, this.consecutiveErrors), this.maxInterval ); } this.onError?.(error); } this.scheduleNext(); } /** Forces immediate execution, useful for reconnection scenarios */ async refreshNow() { this.stop(); await this.execute(); } }; // src/client.ts function featureFlagsClient(options = {}) { const cache = new FlagCache(options.cache); const overrideManager = new SecureOverrideManager(options.overrides); const subscribers = /* @__PURE__ */ new Set(); let context = {}; let cachedFlags = {}; let smartPoller = null; let sessionUnsubscribe = null; let lastSessionId = void 0; const sanitizer = new ContextSanitizer({ strict: options.contextSanitization?.strict ?? true, allowedFields: options.contextSanitization?.allowedFields ? new Set(options.contextSanitization.allowedFields) : void 0, maxSizeForUrl: options.contextSanitization?.maxUrlSize, maxSizeForBody: options.contextSanitization?.maxBodySize, warnOnDrop: options.contextSanitization?.warnOnDrop }); const sanitizationEnabled = options.contextSanitization?.enabled ?? true; const notifySubscribers = /* @__PURE__ */ __name((flags) => { cachedFlags = flags; subscribers.forEach((callback) => callback(flags)); }, "notifySubscribers"); return { id: "feature-flags", $InferServerPlugin: {}, // HTTP methods for feature-flags endpoints - canonical only pathMethods: { // Public endpoints "/feature-flags/evaluate": "POST", "/feature-flags/evaluate-batch": "POST", "/feature-flags/bootstrap": "POST", "/feature-flags/events": "POST", "/feature-flags/events/batch": "POST", "/feature-flags/config": "GET", "/feature-flags/health": "GET", // Admin endpoints (RESTful) "/feature-flags/admin/flags": "GET", "/feature-flags/admin/flags/:id": "GET", "/feature-flags/admin/flags/:id/enable": "POST", "/feature-flags/admin/flags/:id/disable": "POST", "/feature-flags/admin/flags/:flagId/rules": "GET", "/feature-flags/admin/flags/:flagId/rules/:ruleId": "GET", "/feature-flags/admin/flags/:flagId/rules/reorder": "POST", "/feature-flags/admin/flags/:flagId/stats": "GET", "/feature-flags/admin/overrides": "GET", "/feature-flags/admin/overrides/:id": "GET", "/feature-flags/admin/metrics/usage": "GET", "/feature-flags/admin/audit": "GET", "/feature-flags/admin/audit/:id": "GET", "/feature-flags/admin/environments": "GET", "/feature-flags/admin/environments/:id": "GET", "/feature-flags/admin/export": "POST" }, // No atoms exposed currently getAtoms: /* @__PURE__ */ __name((..._args) => ({}), "getAtoms"), getActions: /* @__PURE__ */ __name((fetch, $store, _clientOptions) => { if ($store?.atoms?.session) { const unsubscribe = $store.atoms.session.subscribe( (sessionState) => { const currentSessionId = sessionState?.data?.session?.id; if (currentSessionId !== lastSessionId) { lastSessionId = currentSessionId; cache.invalidateOnSessionChange(currentSessionId); cachedFlags = {}; if (currentSessionId) { notifySubscribers({}); } } } ); sessionUnsubscribe = unsubscribe; } const handleError = /* @__PURE__ */ __name((error) => { if (options.debug) { console.error("[feature-flags]", error); } options.onError?.(error); }, "handleError"); const logEvaluation = /* @__PURE__ */ __name((flag, result) => { if (options.debug) { console.log(`[feature-flags] ${flag}:`, result); } options.onEvaluation?.(flag, result); }, "logEvaluation"); const evaluateFlag = /* @__PURE__ */ __name(async (key, evaluateOptions) => { const overrideValue = overrideManager.get(String(key)); if (overrideValue !== void 0) { return { value: overrideValue, reason: "override" }; } const cached = cache.get(String(key)); if (cached !== void 0) { logEvaluation(String(key), cached); return cached; } try { const keyStr = String(key); const requestBody = { flagKey: keyStr, context: Object.keys(context).length > 0 ? sanitizationEnabled ? sanitizer.sanitizeForBody(context) : context : void 0, default: options.defaults?.[key] }; if (evaluateOptions?.track !== void 0) { requestBody.track = evaluateOptions.track; } if (evaluateOptions?.select !== void 0) { requestBody.select = evaluateOptions.select; } if (evaluateOptions?.debug !== void 0) { requestBody.debug = evaluateOptions.debug; } if (evaluateOptions?.contextInResponse !== void 0) { requestBody.contextInResponse = evaluateOptions.contextInResponse; } const response = await fetch(`/feature-flags/evaluate`, { method: "POST", body: requestBody }); const result = response.data; cache.set(keyStr, result); logEvaluation(keyStr, result); return result; } catch (error) { handleError(error); if (options.defaults?.[key] !== void 0) { return { value: options.defaults[key], reason: "default" }; } return { value: void 0, reason: "not_found" }; } }, "evaluateFlag"); const actions = { featureFlags: { async isEnabled(flag, defaultValue = false) { const result = await evaluateFlag(flag); const value = result.value ?? defaultValue; return Boolean(value); }, async getValue(flag, defaultValue) { const result = await evaluateFlag(flag); return result.value ?? defaultValue; }, async getVariant(flag) { const result = await evaluateFlag(flag); return result.variant || null; }, // Core evaluation methods async evaluate(flag, opts) { let defaultValue = void 0; let environment = void 0; if (opts && typeof opts === "object" && ("default" in opts || "environment" in opts || "select" in opts || "contextInResponse" in opts || "track" in opts || "debug" in opts || "context" in opts)) { defaultValue = opts.default; environment = opts.environment; } const originalContext = { ...context }; if (environment) { const next = { ...context }; next.attributes = { ...next.attributes || {}, environment }; context = next; } try { const result = await evaluateFlag(flag, opts); return defaultValue !== void 0 && result.value === void 0 ? { ...result, value: defaultValue } : result; } finally { context = originalContext; } }, async evaluateMany(keys, opts) { const cachedResults = cache.getMany(keys.map(String)); const results = {}; const uncachedKeys = []; const defaultsMap = opts?.defaults || {}; for (const key of keys) { const cached = cachedResults.get(String(key)); if (cached) { results[key] = cached; logEvaluation(String(key), cached); } else { uncachedKeys.push(String(key)); } } if (uncachedKeys.length === 0) { return results; } try { const originalContext = { ...context }; if (opts?.environment) { const next = { ...context }; next.attributes = { ...next.attributes || {}, environment: opts.environment }; context = next; } const batchRequestBody = { flagKeys: uncachedKeys, defaults: Object.keys(defaultsMap).length > 0 ? Object.fromEntries( uncachedKeys.filter((k) => defaultsMap[k] !== void 0).map((k) => [k, defaultsMap[k]]) ) : void 0, context: Object.keys(context).length > 0 ? sanitizationEnabled ? sanitizer.sanitizeForBody(context) : context : void 0 // Do not pass select='value' from client to keep return type stable // environment support via context enrichment below }; if (opts?.track !== void 0) { batchRequestBody.track = opts.track; } if (opts?.select !== void 0) { batchRequestBody.select = opts.select; } if (opts?.debug !== void 0) { batchRequestBody.debug = opts.debug; } if (opts?.contextInResponse !== void 0) { batchRequestBody.contextInResponse = opts.contextInResponse; } const response = await fetch("/feature-flags/evaluate-batch", { method: "POST", body: batchRequestBody }); context = originalContext; const data = response.data; cache.setMany(data.flags); for (const [key, result] of Object.entries(data.flags)) { results[key] = result; logEvaluation(key, result); } return results; } catch (error) { handleError(error); for (const key of uncachedKeys) { results[key] = { value: defaultsMap[key], reason: "default" }; } return results; } }, async bootstrap(options2) { try { const bootstrapRequestBody = { include: options2?.include, prefix: options2?.prefix, environment: options2?.environment, context: Object.keys(context).length > 0 ? sanitizationEnabled ? sanitizer.sanitizeForBody(context) : context : void 0 }; if (options2?.select !== void 0) { bootstrapRequestBody.select = options2.select; } if (options2?.track !== void 0) { bootstrapRequestBody.track = options2.track; } if (options2?.debug !== void 0) { bootstrapRequestBody.debug = options2.debug; } if (options2?.contextInResponse !== void 0) { bootstrapRequestBody.contextInResponse = options2.contextInResponse; } const response = await fetch(`/feature-flags/bootstrap`, { method: "POST", body: bootstrapRequestBody }); const data = response.data; const flags = {}; for (const [key, result] of Object.entries(data.flags)) { flags[key] = result.value; cache.set(key, result); } notifySubscribers(flags); return flags; } catch (error) { handleError(error); return options2?.defaults || {}; } }, async track(flag, event, value, options2) { try { if (options2?.sampleRate !== void 0 && typeof options2.sampleRate === "number") { if (options2.sampleRate < 0 || options2.sampleRate > 1) { throw new Error("sampleRate must be between 0 and 1"); } if (Math.random() > options2.sampleRate) { if (options2.debug) { console.log( `[feature-flags] Event sampled out (rate: ${options2.sampleRate})` ); } return { success: true, eventId: "sampled_out", sampled: true }; } } const headers = {}; if (options2?.idempotencyKey) { headers["Idempotency-Key"] = options2.idempotencyKey; } const response = await fetch("/feature-flags/events", { method: "POST", headers, body: { flagKey: String(flag), event, properties: value, timestamp: options2?.timestamp, sampleRate: options2?.sampleRate } }); return response.data; } catch (error) { handleError(error); return { success: false, eventId: "" }; } }, async trackBatch(events, options2) { try { const filteredEvents = []; let sampledCount = 0; for (const eventData of events) { const eventSampleRate = eventData.sampleRate ?? options2?.sampleRate; if (eventSampleRate !== void 0 && typeof eventSampleRate === "number") { if (eventSampleRate < 0 || eventSampleRate > 1) { continue; } if (Math.random() > eventSampleRate) { sampledCount++; continue; } } filteredEvents.push(eventData); } if (filteredEvents.length === 0) { return { success: 0, failed: 0, sampled: sampledCount, batchId: "sampled_out" }; } const transformedEvents = filteredEvents.map( ({ flag, event, data, timestamp, sampleRate }) => ({ flagKey: String(flag), event, properties: data, timestamp, sampleRate }) ); const response = await fetch("/feature-flags/events/batch", { method: "POST", body: { events: transformedEvents, sampleRate: options2?.sampleRate, idempotencyKey: options2?.idempotencyKey } }); const result = response.data; return { ...result, sampled: sampledCount + (result.sampled || 0) }; } catch (error) { handleError(error); return { success: 0, failed: events.length, sampled: 0, batchId: options2?.idempotencyKey || "" }; } }, setContext(newContext) { if (options.debug && sanitizationEnabled) { const warnings = ContextSanitizer.validate(newContext); if (warnings.length > 0) { console.warn( "[feature-flags] Context validation warnings:\n" + warnings.join("\n") ); } } context = { ...context, ...newContext }; cache.clear(); }, getContext() { return { ...context }; }, async prefetch(flags) { const uncached = flags.filter( (key) => cache.get(String(key)) === void 0 ); if (uncached.length > 0) { await actions.featureFlags.evaluateMany(uncached); } }, clearCache() { cache.clear(); }, setOverride(flag, value) { const success = overrideManager.set(String(flag), value); if (success) { notifySubscribers({ ...cachedFlags, [flag]: value }); } }, clearOverrides() { overrideManager.clear(); actions.featureFlags.refresh(); }, async refresh() { cache.clear(); const flags = await actions.featureFlags.bootstrap(); notifySubscribers(flags); }, subscribe(callback) { subscribers.add(callback); callback(cachedFlags); return () => { subscribers.delete(callback); }; }, // Admin API methods admin: { // Flag CRUD operations flags: { async list(options2) { try { const response = await fetch("/feature-flags/admin/flags", { method: "GET", query: options2 }); return response.data; } catch (error) { handleError(error); return { flags: [], page: { limit: 50, hasMore: false } }; } }, async create(flag) { try { const response = await fetch("/feature-flags/admin/flags", { method: "POST", body: flag }); return response.data; } catch (error) { handleError(error); throw error; } }, async get(id) { try { const response = await fetch( `/feature-flags/admin/flags/${id}`, { method: "GET" } ); return response.data; } catch (error) { handleError(error); throw error; } }, async update(id, updates) { try { const response = await fetch( `/feature-flags/admin/flags/${id}`, { method: "PATCH", body: updates } ); return response.data; } catch (error) { handleError(error); throw error; } }, async delete(id) { try { const response = await fetch( `/feature-flags/admin/flags/${id}`, { method: "DELETE" } ); return response.data; } catch (error) { handleError(error); throw error; } }, async enable(id) { try { const response = await fetch( `/feature-flags/admin/flags/${id}/enable`, { method: "POST" } ); return response.data; } catch (error) { handleError(error); throw error; } }, async disable(id) { try { const response = await fetch( `/feature-flags/admin/flags/${id}/disable`, { method: "POST" } ); return response.data; } catch (error) { handleError(error); throw error; } } }, // Rule CRUD operations rules: { async list(flagId) { try { const response = await fetch( `/feature-flags/admin/flags/${flagId}/rules`, { method: "GET" } ); return response.data; } catch (error) { handleError(error); return { rules: [] }; } }, async create(rule) { try { const response = await fetch( `/feature-flags/admin/flags/${rule.flagId}/rules`, { method: "POST", body: rule } ); return response.data; } catch (error) { handleError(error); throw error; } }, async get(flagId, ruleId) { try { const response = await fetch( `/feature-flags/admin/flags/${flagId}/rules/${ruleId}`, { method: "GET" } ); return response.data; } catch (error) { handleError(error); throw error; } }, async update(flagId, ruleId, updates) { try { const response = await fetch( `/feature-flags/admin/flags/${flagId}/rules/${ruleId}`, { method: "PATCH", body: updates } ); return response.data; } catch (error) { handleError(error); throw error; } }, async delete(flagId, ruleId) { try { const response = await fetch( `/feature-flags/admin/flags/${flagId}/rules/${ruleId}`, { method: "DELETE" } ); return response.data; } catch (error) { handleError(error); throw error; } }, async reorder(flagId, ids) { try { const response = await fetch( `/feature-flags/admin/flags/${flagId}/rules/reorder`, { method: "POST", body: { ids } } ); return response.data; } catch (error) { handleError(error); throw error; } } }, // Override CRUD operations overrides: { async list(options2) { try { const response = await fetch( "/feature-flags/admin/overrides", { method: "GET", query: options2 } ); return response.data; } catch (error) { handleError(error); return { overrides: [], page: { limit: 50, hasMore: false } }; } }, async create(override) { try { const response = await fetch( "/feature-flags/admin/overrides", { method: "POST", body: override } ); return response.data; } catch (error) { handleError(error); throw error; } }, async get(id) { try { const response = await fetch( `/feature-flags/admin/overrides/${id}`, { method: "GET" } ); return response.data; } catch (error) { handleError(error); throw error; } }, async update(id, updates) { try { const response = await fetch( `/feature-flags/admin/overrides/${id}`, { method: "PATCH", body: updates } ); return response.data; } catch (error) { handleError(error); throw error; } }, async delete(id) { try { const response = await fetch( `/feature-flags/admin/overrides/${id}`, { method: "DELETE" } ); return response.data; } catch (error) { handleError(error); throw error; } } }, // Analytics analytics: { stats: { async get(flagId, options2 = {}) { try { const response = await fetch( `/feature-flags/admin/flags/${flagId}/stats`, { method: "GET", query: options2 } ); return response.data; } catch (error) { handleError(error); return { stats: {} }; } } }, usage: { async get(options2 = {}) { try { const response = await fetch( "/feature-flags/admin/metrics/usage", { method: "GET", query: options2 } ); return response.data; } catch (error) { handleError(error); return { usage: {} }; } } } }, // Audit logs audit: { async list(_options) { try { const response = await fetch("/feature-flags/admin/audit", { method: "GET" }); return response.data; } catch (error) { handleError(error); return { entries: [] }; } }, async get(id) { try { const response = await fetch( `/feature-flags/admin/audit/${id}`, { method: "GET" } ); return response.data; } catch (error) { handleError(error); throw error; } } }, // Environments environments: { async list() { try { const response = await fetch( "/feature-flags/admin/environments", { method: "GET" } ); return response.data; } catch (error) { handleError(error); return { environments: [] }; } }, async create(env) { try { const response = await fetch( "/feature-flags/admin/environments", { method: "POST", body: env } ); return response.data; } catch (error) { handleError(error); throw error; } }, async update(id, updates) { try { const response = await fetch( `/feature-flags/admin/environments/${id}`, { method: "PATCH", body: updates } ); return response.data; } catch (error) { handleError(error); throw error; } }, async delete(id) { try { const response = await fetch( `/feature-flags/admin/environments/${id}`, { method: "DELETE" } ); return response.data; } catch (error) { handleError(error); throw error; } } }, // Data exports exports: { async create(options2) { try { const response = await fetch("/feature-flags/admin/export", { method: "POST", body: options2 }); return response.data; } catch (error) { handleError(error); throw error; } } } }, dispose() { if (smartPoller) { smartPoller.stop(); smartPoller = null; } if (sessionUnsubscribe) { sessionUnsubscribe(); sessionUnsubscribe = null; } cache.clear(); subscribers.clear(); overrideManager.dispose(); } } }; if (options.polling?.enabled && options.polling.interval && typeof globalThis !== "undefined" && typeof globalThis.setTimeout === "function") { if (smartPoller) { smartPoller.stop(); } smartPoller = new SmartPoller( options.polling.interval, async () => { await actions.featureFlags.refresh(); }, (error) => { handleError( new Error( `Polling