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,022 lines (1,017 loc) 29.9 kB
import { __name } from "./chunk-SHUYVCID.js"; // 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(); } /** * Force an immediate refresh (e.g., on reconnection) */ async refreshNow() { this.stop(); await this.execute(); } }; // 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 parameters (GET requests). * More strict size limits due to URL length constraints. */ 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 request body (POST requests). * Less strict size limits than URL. */ 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 logic. */ 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 only the most 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 is 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; } delete result[entries[i][0]]; } return result; } /** * Validates that context doesn't contain sensitive data. * Returns array of warnings if issues found. */ 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"); } /** * Detect if we're in a production environment. * * @returns true if production, false otherwise * @decision Check multiple indicators for robustness: * - NODE_ENV (most common) * - window.location.hostname (production domains) * - Build-time flags (VITE_ENV, etc.) */ 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"; } /** * Check if overrides are allowed in current environment. */ isOverrideAllowed() { if (this.environment === "production" && !this.allowInProduction) { return false; } return true; } /** * Set a feature flag override. * * @security Blocked in production unless explicitly allowed. * @param flag - Flag key * @param value - Override value * @returns true if override was 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; } /** * Get an override value if it exists and hasn't expired. */ 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; } /** * Check if an override exists (without returning value). */ has(flag) { return this.get(flag) !== void 0; } /** * Clear a specific override. */ delete(flag) { this.overrides.delete(flag); if (this.persist) { this.saveToStorage(); } } /** * Clear all overrides. */ clear() { this.overrides.clear(); if (this.persist) { this.clearStorage(); } } /** * Get all active 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; } /** * Load overrides from localStorage. */ 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 ); } } /** * Save 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 ); } } /** * Clear overrides from localStorage. */ 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 ); } } /** * Start periodic cleanup of expired overrides. */ startCleanupTimer() { this.cleanupTimer = setInterval(() => { this.cleanupExpired(); }, 6e4); if (typeof process !== "undefined" && this.cleanupTimer.unref) { this.cleanupTimer.unref(); } } /** * Remove expired overrides. */ 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(); } } /** * Clean up resources. */ dispose() { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); } } }; // src/client.ts var FlagCache = class { // Tracks LRU order 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 = 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; const keys = Object.keys(this.storage); for (const key of keys) { if (key.startsWith("ff_") && !key.startsWith(this.keyPrefix)) { this.storage.removeItem(key); } } } loadFromStorage() { if (!this.storage) return; const keys = Object.keys(this.storage); for (const key of keys) { 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) { const keys = Object.keys(this.storage); for (const key of keys) { if (key.startsWith(this.keyPrefix)) { this.storage.removeItem(key); } } } } // Batch get reduces N cache lookups to 1 pass. 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; } setMany(entries, ttl) { for (const [key, value] of Object.entries(entries)) { this.set(key, value, ttl); } } /** * Session change triggers full cache clear. * Prevents flag leakage between users on shared devices. */ invalidateOnSessionChange(newSessionId) { const sessionChanged = newSessionId !== this.currentSessionId; this.currentSessionId = newSessionId; if (sessionChanged) { this.clear(); } } // Get current session for tracking getCurrentSessionId() { return this.currentSessionId; } }; 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: {}, getAtoms: /* @__PURE__ */ __name((atoms) => { if (atoms?.session) { const unsubscribe = atoms.session.subscribe((sessionState) => { const currentSessionId = sessionState?.data?.session?.id; if (currentSessionId !== lastSessionId) { lastSessionId = currentSessionId; cache.invalidateOnSessionChange(currentSessionId); cachedFlags = {}; if (currentSessionId) { notifySubscribers({}); } } }); sessionUnsubscribe = unsubscribe; } return {}; }, "getAtoms"), getActions: /* @__PURE__ */ __name((fetch) => { 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) => { 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 params = new URLSearchParams(); const keyStr = String(key); if (options.defaults?.[key] !== void 0) { params.set( "default", JSON.stringify(options.defaults[key]) ); } if (Object.keys(context).length > 0) { const sanitizedContext = sanitizationEnabled ? sanitizer.sanitizeForUrl(context) : JSON.stringify(context); if (sanitizedContext) { params.set("context", sanitizedContext); } } const response = await fetch( `/api/flags/evaluate/${keyStr}?${params}`, { method: "GET" } ); 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; }, async getAllFlags() { try { const params = new URLSearchParams(); if (Object.keys(context).length > 0) { const sanitizedContext = sanitizationEnabled ? sanitizer.sanitizeForUrl(context) : JSON.stringify(context); if (sanitizedContext) { params.set("context", sanitizedContext); } } const response = await fetch(`/api/flags/all?${params}`, { method: "GET" }); 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 options.defaults || {}; } }, async evaluateBatch(keys) { const cachedResults = cache.getMany(keys.map(String)); const results = {}; const uncachedKeys = []; 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 response = await fetch("/api/flags/evaluate/batch", { method: "POST", body: { keys: uncachedKeys, defaults: options.defaults ? Object.fromEntries( uncachedKeys.filter( (k) => options.defaults[k] !== void 0 ).map((k) => [ k, options.defaults[k] ]) ) : void 0, context: Object.keys(context).length > 0 ? sanitizationEnabled ? sanitizer.sanitizeForBody(context) : context : void 0 } }); 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: options.defaults?.[key], reason: "default" }; } return results; } }, async track(flag, event, value) { try { await fetch("/api/flags/track", { method: "POST", body: { flagKey: String(flag), event, data: value } }); } catch (error) { handleError(error); } }, 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.evaluateBatch(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.getAllFlags(); notifySubscribers(flags); }, subscribe(callback) { subscribers.add(callback); callback(cachedFlags); return () => { subscribers.delete(callback); }; }, 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 refresh failed: ${error.message}`)); } ); smartPoller.start(); } return actions; }, "getActions") }; } __name(featureFlagsClient, "featureFlagsClient"); var client_default = featureFlagsClient; export { client_default as default, featureFlagsClient };