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

654 lines (644 loc) 24.1 kB
import * as z from 'zod'; /** * LRU Cache with TTL and flag-specific invalidation. * @security Keys hashed to prevent PII exposure * @see src/storage/types.ts */ declare class LRUCache<T> { private cache; /** Reverse index: flagKey -> hashed cache keys for bulk invalidation */ private flagIndex; private maxSize; private defaultTTL; /** Hit/miss counters for performance monitoring */ private hits; private misses; constructor(options?: { /** Max entries before LRU eviction */ maxSize?: number; /** Entry TTL in milliseconds */ defaultTTL?: number; }); /** * Creates deterministic cache key from evaluation context. * @security Hashes to prevent PII exposure */ private createCacheKey; /** Recursively sorts object keys for deterministic serialization */ private stabilizeObject; /** Extracts flag key from context for reverse indexing */ private extractFlagKey; /** Removes entry and cleans up reverse index */ private evictEntry; /** Gets cached value, promotes to MRU position */ get(keyData: any): T | null; /** Caches value with TTL, evicts LRU if at capacity */ set(keyData: any, value: T, ttl?: number): void; /** Checks if key exists and is not expired */ has(keyData: any): boolean; /** Deletes specific cache entry */ delete(keyData: any): boolean; /** Clears all cache entries and resets stats */ clear(): void; /** Returns cache usage statistics */ getStats(): { size: number; maxSize: number; expired: number; hitRate: number; hits: number; misses: number; totalRequests: number; }; /** Resets hit/miss counters for monitoring windows */ resetStats(): void; /** Removes expired entries, returns count cleaned */ cleanup(): number; /** * Invalidates all cache entries for a flag. * @usage Called by admin middleware after flag updates */ invalidateByFlag(flagKey: string): number; } /** * Runtime validation schemas. Rejects invalid input before DB write. * * @invariant Flag keys: /^[a-z0-9-_]+$/i (URL-safe) * @invariant Percentages: 0 ≤ n ≤ 100 * @invariant Variant weights: Σ(weights) = 100 ± 0.01 * @invariant Priority: integer, -1000 ≤ n ≤ 1000 * * @decision 0.01 tolerance handles IEEE 754 precision * @decision Empty variants allowed (feature without A/B test) */ declare const flagTypeSchema: z.ZodEnum<{ string: "string"; number: "number"; boolean: "boolean"; json: "json"; }>; declare const evaluationReasonSchema: z.ZodEnum<{ default: "default"; rule_match: "rule_match"; override: "override"; percentage_rollout: "percentage_rollout"; disabled: "disabled"; not_found: "not_found"; }>; declare const auditActionSchema: z.ZodEnum<{ enabled: "enabled"; disabled: "disabled"; created: "created"; updated: "updated"; deleted: "deleted"; rule_added: "rule_added"; rule_updated: "rule_updated"; rule_deleted: "rule_deleted"; override_added: "override_added"; override_removed: "override_removed"; }>; declare const conditionOperatorSchema: z.ZodEnum<{ equals: "equals"; not_equals: "not_equals"; contains: "contains"; not_contains: "not_contains"; starts_with: "starts_with"; ends_with: "ends_with"; greater_than: "greater_than"; less_than: "less_than"; greater_than_or_equal: "greater_than_or_equal"; less_than_or_equal: "less_than_or_equal"; in: "in"; not_in: "not_in"; regex: "regex"; }>; declare const conditionSchema: z.ZodObject<{ attribute: z.ZodString; operator: z.ZodEnum<{ equals: "equals"; not_equals: "not_equals"; contains: "contains"; not_contains: "not_contains"; starts_with: "starts_with"; ends_with: "ends_with"; greater_than: "greater_than"; less_than: "less_than"; greater_than_or_equal: "greater_than_or_equal"; less_than_or_equal: "less_than_or_equal"; in: "in"; not_in: "not_in"; regex: "regex"; }>; value: z.ZodAny; }, z.core.$strip>; declare const ruleConditionsSchema: z.ZodType<{ all?: z.infer<typeof conditionSchema>[]; any?: z.infer<typeof conditionSchema>[]; not?: any; }>; declare const flagRuleInputSchema: z.ZodObject<{ name: z.ZodOptional<z.ZodString>; priority: z.ZodDefault<z.ZodNumber>; conditions: z.ZodType<{ all?: z.infer<typeof conditionSchema>[]; any?: z.infer<typeof conditionSchema>[]; not?: any; }, unknown, z.core.$ZodTypeInternals<{ all?: z.infer<typeof conditionSchema>[]; any?: z.infer<typeof conditionSchema>[]; not?: any; }, unknown>>; value: z.ZodAny; percentage: z.ZodOptional<z.ZodNumber>; enabled: z.ZodDefault<z.ZodBoolean>; }, z.core.$strip>; declare const featureFlagInputSchema: z.ZodObject<{ key: z.ZodString; name: z.ZodString; description: z.ZodOptional<z.ZodString>; type: z.ZodDefault<z.ZodEnum<{ string: "string"; number: "number"; boolean: "boolean"; json: "json"; }>>; enabled: z.ZodDefault<z.ZodBoolean>; defaultValue: z.ZodOptional<z.ZodAny>; rolloutPercentage: z.ZodDefault<z.ZodNumber>; variants: z.ZodOptional<z.ZodArray<z.ZodObject<{ key: z.ZodString; value: z.ZodAny; weight: z.ZodNumber; metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>; }, z.core.$strip>>>; metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>; }, z.core.$strip>; declare const evaluationContextSchema: z.ZodObject<{ userId: z.ZodOptional<z.ZodString>; email: z.ZodOptional<z.ZodString>; role: z.ZodOptional<z.ZodString>; organizationId: z.ZodOptional<z.ZodString>; attributes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>; }, z.core.$strip>; declare const flagOverrideInputSchema: z.ZodObject<{ flagId: z.ZodString; userId: z.ZodString; value: z.ZodAny; enabled: z.ZodDefault<z.ZodBoolean>; variant: z.ZodOptional<z.ZodString>; reason: z.ZodOptional<z.ZodString>; expiresAt: z.ZodOptional<z.ZodDate>; }, z.core.$strip>; declare const flagEvaluationInputSchema: z.ZodObject<{ flagId: z.ZodString; userId: z.ZodOptional<z.ZodString>; sessionId: z.ZodOptional<z.ZodString>; value: z.ZodAny; variant: z.ZodOptional<z.ZodString>; reason: z.ZodOptional<z.ZodEnum<{ default: "default"; rule_match: "rule_match"; override: "override"; percentage_rollout: "percentage_rollout"; disabled: "disabled"; not_found: "not_found"; }>>; context: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>; }, z.core.$strip>; declare const flagAuditInputSchema: z.ZodObject<{ flagId: z.ZodString; userId: z.ZodOptional<z.ZodString>; action: z.ZodEnum<{ enabled: "enabled"; disabled: "disabled"; created: "created"; updated: "updated"; deleted: "deleted"; rule_added: "rule_added"; rule_updated: "rule_updated"; rule_deleted: "rule_deleted"; override_added: "override_added"; override_removed: "override_removed"; }>; previousValue: z.ZodOptional<z.ZodAny>; newValue: z.ZodOptional<z.ZodAny>; metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>; }, z.core.$strip>; /** * TypeScript type definitions for compile-time safety. * * @decision Entity types = Zod infer + DB fields (id, timestamps) * @decision Date objects not strings for type safety * @performance CacheConfig.ttl: 60-300s (freshness vs load) * @performance AnalyticsConfig.sampleRate: 0.01-0.1 (high traffic) * @see ./validation.ts for Zod schemas */ type FlagType = z.infer<typeof flagTypeSchema>; type EvaluationReason = z.infer<typeof evaluationReasonSchema>; type AuditAction = z.infer<typeof auditActionSchema>; type ConditionOperator = z.infer<typeof conditionOperatorSchema>; type RuleConditions = z.infer<typeof ruleConditionsSchema>; type EvaluationContext = z.infer<typeof evaluationContextSchema>; type FeatureFlag = z.infer<typeof featureFlagInputSchema> & { id: string; organizationId?: string; createdAt: Date; updatedAt: Date; }; type FlagRule = z.infer<typeof flagRuleInputSchema> & { id: string; flagId: string; createdAt: Date; }; type FlagOverride = z.infer<typeof flagOverrideInputSchema> & { id: string; createdAt: Date; }; type FlagEvaluation = z.infer<typeof flagEvaluationInputSchema> & { id: string; evaluatedAt: Date; }; type FlagAudit = z.infer<typeof flagAuditInputSchema> & { id: string; createdAt: Date; }; /** * Privacy-aware opt-in data collection for GDPR/CCPA compliance. * @example { collectDevice: true, collectGeo: false } */ interface ContextCollectionOptions { /** Collect device type, browser, OS from user agent string */ collectDevice?: boolean; /** Collect geographic data from CDN headers (country, region, city) */ collectGeo?: boolean; /** Collect custom x-feature-flag-* and x-targeting-* headers */ collectCustomHeaders?: boolean; /** Collect client info like IP address and referrer URL */ collectClientInfo?: boolean; /** Whitelist of specific attributes to collect (filters all others) */ allowedAttributes?: string[]; } /** * Security validation for feature flag context data. * Prevents prototype pollution, XSS, and injection attacks. * @see plugins/feature-flags/src/middleware/types.ts */ /** Header validation config with type checking and sanitization rules. */ interface HeaderConfig { name: string; type: "string" | "number" | "boolean" | "json" | "enum"; maxLength?: number; enumValues?: string[]; pattern?: RegExp; required?: boolean; sanitize?: (value: string) => string; } /** Context validation limits to prevent DoS and memory exhaustion. */ interface ValidationConfig { /** Max string length in characters (default: 10KB) */ maxStringLength?: number; /** Max object nesting depth (default: 5) */ maxObjectDepth?: number; /** Max array length (default: 100) */ maxArrayLength?: number; /** Max total JSON size in bytes (default: 50KB) */ maxTotalSize?: number; /** Allowed key pattern (default: /^[a-zA-Z0-9_.-]+$/) */ allowedKeyPattern?: RegExp; } /** Production-ready header configs for common feature flag use cases. */ declare const DEFAULT_HEADER_CONFIG: HeaderConfig[]; /** * Storage adapter interface for memory, database, and Redis backends. * Provides unified API for feature flag CRUD, analytics, and audit operations. * @see src/storage/database.ts, src/storage/memory.ts, src/storage/redis.ts */ interface StorageAdapter { /** Initialize storage connections and schema. Optional for memory adapter. */ initialize?(): Promise<void>; /** Cleanup connections and release resources. */ close?(): Promise<void>; /** Create new feature flag with auto-generated ID and timestamps */ createFlag(flag: Omit<FeatureFlag, "id" | "createdAt" | "updatedAt">): Promise<FeatureFlag>; /** Get flag by key with optional organization scoping */ getFlag(key: string, organizationId?: string): Promise<FeatureFlag | null>; /** Get flag by internal ID */ getFlagById(id: string): Promise<FeatureFlag | null>; /** List flags with optional filtering, sorting, and pagination */ listFlags(organizationId?: string, options?: ListOptions): Promise<FeatureFlag[]>; /** Update flag and auto-set updatedAt timestamp */ updateFlag(id: string, updates: Partial<FeatureFlag>): Promise<FeatureFlag>; /** Delete flag and cascade to rules/overrides */ deleteFlag(id: string): Promise<void>; /** Create rule with auto-generated ID and timestamp */ createRule(rule: Omit<FlagRule, "id" | "createdAt">): Promise<FlagRule>; getRule(id: string): Promise<FlagRule | null>; /** Get all rules for flag ordered by priority */ getRulesForFlag(flagId: string): Promise<FlagRule[]>; updateRule(id: string, updates: Partial<FlagRule>): Promise<FlagRule>; deleteRule(id: string): Promise<void>; /** Reorder rules by updating priority based on array position */ reorderRules(flagId: string, ruleIds: string[]): Promise<void>; /** Create user override with auto-generated ID */ createOverride(override: Omit<FlagOverride, "id" | "createdAt">): Promise<FlagOverride>; /** Get override for specific flag and user combination */ getOverride(flagId: string, userId: string): Promise<FlagOverride | null>; getOverrideById(id: string): Promise<FlagOverride | null>; updateOverride(id: string, updates: Partial<FlagOverride>): Promise<FlagOverride>; listOverrides(flagId?: string, userId?: string): Promise<FlagOverride[]>; deleteOverride(id: string): Promise<void>; /** Record flag evaluation event for analytics */ trackEvaluation(tracking: EvaluationTracking): Promise<void>; getEvaluations(flagId: string, options?: ListOptions): Promise<FlagEvaluation[]>; /** Get aggregated evaluation metrics for flag */ getEvaluationStats(flagId: string, period?: DateRange, options?: AnalyticsOptions): Promise<EvaluationStats>; getUsageMetrics(organizationId?: string, period?: DateRange, options?: AnalyticsOptions): Promise<UsageMetrics>; /** Record audit event for flag operations */ logAudit(entry: AuditLogEntry): Promise<void>; getAuditLogs(options?: AuditQueryOptions): Promise<FlagAudit[]>; getAuditEntry(id: string): Promise<FlagAudit | null>; /** Remove old audit logs and return count deleted */ cleanupAuditLogs(olderThan: Date): Promise<number>; bulkCreateFlags?(flags: Array<Omit<FeatureFlag, "id" | "createdAt" | "updatedAt">>): Promise<FeatureFlag[]>; bulkDeleteFlags?(ids: string[]): Promise<void>; } /** Pagination and filtering options for list operations */ interface ListOptions { /** Maximum records to return */ limit?: number; /** Number of records to skip */ offset?: number; /** Field name for sorting */ orderBy?: string; /** Sort direction */ orderDirection?: "asc" | "desc"; /** Field-value filters */ filter?: Record<string, any>; } /** Time period filter for analytics queries */ interface DateRange { /** Period start timestamp (inclusive) */ start: Date; /** Period end timestamp (inclusive) */ end: Date; } /** Selective metrics configuration for analytics queries */ type MetricsFilter = Array<"total" | "uniqueUsers" | "errorRate" | "avgLatency" | "variants" | "reasons">; /** Analytics query options for performance optimization */ interface AnalyticsOptions { /** Granularity for time-series data */ granularity?: "hour" | "day" | "week" | "month"; /** Timezone for date calculations */ timezone?: string; /** Selective metrics to compute (performance optimization) */ metrics?: MetricsFilter; } /** Aggregated flag evaluation metrics with selective computation */ interface EvaluationStats { /** Total evaluation count - included when metrics contains 'total' or undefined */ totalEvaluations?: number; /** Distinct user count - included when metrics contains 'uniqueUsers' or undefined */ uniqueUsers?: number; /** Variant distribution counts - included when metrics contains 'variants' or undefined */ variants?: Record<string, number>; /** Evaluation reason counts - included when metrics contains 'reasons' or undefined */ reasons?: Record<string, number>; /** Average response time in ms - included when metrics contains 'avgLatency' or undefined */ avgLatency?: number; /** Error percentage (0-1) - included when metrics contains 'errorRate' or undefined */ errorRate?: number; } /** Audit log query filters and pagination */ interface AuditQueryOptions extends ListOptions { /** Filter by user who performed action */ userId?: string; /** Filter by flag ID */ flagId?: string; /** Filter by action type */ action?: string; /** Filter events after this date */ startDate?: Date; /** Filter events before this date */ endDate?: Date; } /** Organization-level usage analytics with selective computation */ interface UsageMetrics { /** Total flag count - included when metrics contains 'total' or undefined */ totalFlags?: number; /** Enabled flag count - included when metrics contains 'total' or undefined */ activeFlags?: number; /** Total evaluation count - included when metrics contains 'total' or undefined */ totalEvaluations?: number; /** Distinct user count - included when metrics contains 'uniqueUsers' or undefined */ uniqueUsers?: number; /** Most evaluated flags - included when metrics contains 'total' or undefined */ topFlags?: Array<{ key: string; evaluations: number; uniqueUsers: number; }>; /** Daily evaluation counts - included when metrics contains 'total' or undefined */ evaluationsByDate?: Record<string, number>; /** Error rate percentage - included when metrics contains 'errorRate' or undefined */ errorRate?: number; /** Average latency in ms - included when metrics contains 'avgLatency' or undefined */ avgLatency?: number; } /** Type utilities for schema validation and type inference */ /** Extracts boolean flag keys, constrains isEnabled() to boolean flags only */ type BooleanFlags<Schema extends Record<string, any>> = { [K in keyof Schema]: Schema[K] extends boolean ? K : never; }[keyof Schema]; /** Validates schema contains only serializable flag types */ type ValidateFlagSchema<T> = T extends Record<string, any> ? { [K in keyof T]: T[K] extends boolean | string | number | null | undefined ? T[K] : T[K] extends Array<infer U> ? U extends boolean | string | number | null | undefined ? T[K] : never : never; } : never; /** Infers specific flag value type from schema */ type InferFlagValue<Schema extends Record<string, any>, K extends keyof Schema> = Schema[K]; /** Plugin context shared across components, storage, and middleware */ interface PluginContext { /** Better Auth instance (type not exported directly) */ auth: any; /** Storage adapter for flag persistence */ storage: StorageAdapter; /** Normalized plugin configuration */ config: PluginConfig; /** LRU cache for flag evaluations */ cache: LRUCache<CacheEntry>; } /** Normalized plugin configuration with defaults applied */ interface PluginConfig { storage: "memory" | "database" | "redis"; debug: boolean; analytics: { trackUsage: boolean; trackPerformance: boolean; }; adminAccess: { enabled: boolean; roles: string[]; }; multiTenant: { enabled: boolean; useOrganizations: boolean; }; caching: { enabled: boolean; ttl: number; maxSize?: number; }; audit: { enabled: boolean; retentionDays: number; }; contextCollection: ContextCollectionOptions; customHeaders?: { enabled: boolean; whitelist?: HeaderConfig[]; strict?: boolean; logInvalid?: boolean; }; contextValidation?: ValidationConfig; flags: Record<string, StaticFlagConfig>; } /** Static flag configuration defined in plugin options */ interface StaticFlagConfig { /** Flag enabled state */ enabled?: boolean; /** Default value when no rules match */ default?: boolean; /** Percentage rollout (0-100) */ rolloutPercentage?: number; /** User targeting rules */ targeting?: { /** Required user roles */ roles?: string[]; /** Specific user IDs */ userIds?: string[]; /** Custom attribute matching */ attributes?: Record<string, any>; }; /** A/B test variants with weights */ variants?: Array<{ /** Variant identifier */ key: string; /** Variant value */ value: any; /** Traffic allocation percentage */ weight?: number; }>; } /** Cache entry storing flag evaluation results with metadata */ interface CacheEntry { /** Evaluated flag value */ value: any; /** A/B test variant if applicable */ variant?: string; /** Evaluation reason (rule_match, percentage_rollout, etc.) */ reason: string; /** Additional evaluation metadata */ metadata?: Record<string, any>; /** Evaluation timestamp */ timestamp: number; /** Cache TTL in milliseconds */ ttl: number; } /** Audit log entry for tracking flag operations */ interface AuditLogEntry { /** User performing action */ userId: string; /** Action type (create, update, delete) */ action: string; /** Flag key if applicable */ flagKey?: string; /** Flag ID if applicable (takes precedence over flagKey) */ flagId?: string; /** Organization ID for multi-tenant scoping */ organizationId?: string; /** Additional context data */ metadata?: Record<string, any>; /** Action timestamp */ timestamp?: Date; } /** Analytics tracking data for flag evaluations */ interface EvaluationTracking { /** Flag key that was evaluated */ flagKey: string; /** User ID for tracking */ userId: string; /** Organization ID for multi-tenant scoping */ organizationId?: string; /** Evaluation context data */ context?: EvaluationContext; /** Evaluation timestamp */ timestamp: Date; /** Evaluated flag value */ value?: any; /** A/B test variant if applicable */ variant?: string; /** Evaluation reason */ reason?: EvaluationReason; } interface FeatureFlagsOptions { flags?: { [key: string]: { enabled?: boolean; default?: boolean; rolloutPercentage?: number; targeting?: { roles?: string[]; userIds?: string[]; attributes?: Record<string, any>; }; variants?: Array<{ key: string; value: any; weight?: number; }>; }; }; storage?: "memory" | "database" | "redis"; debug?: boolean; analytics?: { trackUsage?: boolean; trackPerformance?: boolean; }; adminAccess?: { enabled?: boolean; roles?: string[]; }; multiTenant?: { enabled?: boolean; useOrganizations?: boolean; }; caching?: { enabled?: boolean; ttl?: number; maxSize?: number; }; audit?: { enabled?: boolean; retentionDays?: number; }; /** * Configure what context data to collect for flag evaluation. * By default, only basic session data is collected for privacy. */ contextCollection?: ContextCollectionOptions; /** * Configure custom header processing for feature flag evaluation. * Provides a secure whitelist-based approach for header extraction. */ customHeaders?: { enabled?: boolean; whitelist?: HeaderConfig[]; strict?: boolean; logInvalid?: boolean; }; /** * Configure validation rules for context data. * Helps prevent memory exhaustion and security issues. */ contextValidation?: ValidationConfig; } export { type AuditAction as A, type BooleanFlags as B, type ConditionOperator as C, DEFAULT_HEADER_CONFIG as D, type EvaluationContext as E, type FeatureFlagsOptions as F, type HeaderConfig as H, type InferFlagValue as I, type PluginContext as P, type RuleConditions as R, type ValidationConfig as V, type EvaluationReason as a, type FeatureFlag as b, type FlagAudit as c, type FlagEvaluation as d, type FlagOverride as e, type FlagRule as f, type FlagType as g, type ContextCollectionOptions as h, type ValidateFlagSchema as i };