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

974 lines (968 loc) 37.1 kB
import * as better_auth_client from 'better-auth/client'; import * as better_call from 'better-call'; import * as better_auth from 'better-auth'; import * as z from 'zod'; import { F as FeatureFlagsOptions, E as EvaluationContext } from './types-_f512wNn.js'; export { B as BooleanFlags, I as InferFlagValue, i as ValidateFlagSchema } from './types-_f512wNn.js'; /** * Secure flag override manager with environment detection. * @security Blocks production overrides unless explicitly enabled * @usage Debug/testing environments only by default */ interface OverrideConfig { /** DANGEROUS: Allow overrides in production */ allowInProduction?: boolean; /** Override TTL in ms (default: 1 hour) */ ttl?: number; /** Persist to localStorage */ persist?: boolean; /** Storage key prefix */ keyPrefix?: string; /** Override environment detection for testing */ environment?: "development" | "production"; } /** Creates Better Auth plugin for feature flags with full configuration */ declare function createFeatureFlagsPlugin(options?: FeatureFlagsOptions): { id: "feature-flags"; schema: { featureFlag: { modelName: string; fields: { key: { type: "string"; required: true; unique: true; }; name: { type: "string"; required: true; }; description: { type: "string"; required: false; }; type: { type: "string"; defaultValue: string; validator: { input: z.ZodEnum<{ string: "string"; number: "number"; boolean: "boolean"; json: "json"; }>; output: z.ZodEnum<{ string: "string"; number: "number"; boolean: "boolean"; json: "json"; }>; }; }; enabled: { type: "boolean"; defaultValue: false; }; defaultValue: { type: "string"; required: false; transform: { input(value: any): string | null; output(value: any): any; }; }; rolloutPercentage: { type: "number"; defaultValue: number; validator: { input: z.ZodNumber; output: z.ZodNumber; }; }; organizationId: { type: "string"; references: { model: string; field: string; onDelete: "cascade"; }; required: false; }; createdAt: { type: "date"; required: true; defaultValue: () => Date; }; updatedAt: { type: "date"; required: true; defaultValue: () => Date; }; variants: { type: "string"; required: false; transform: { input(value: any): string | null; output(value: any): any; }; }; metadata: { type: "string"; required: false; transform: { input(value: any): string | null; output(value: any): any; }; }; }; }; flagRule: { modelName: string; fields: { flagId: { type: "string"; references: { model: string; field: string; onDelete: "cascade"; }; required: true; }; priority: { type: "number"; defaultValue: number; validator: { input: z.ZodNumber; output: z.ZodNumber; }; }; name: { type: "string"; required: false; }; conditions: { type: "string"; required: true; transform: { input(value: any): string; output(value: any): any; }; }; value: { type: "string"; required: true; transform: { input(value: any): string; output(value: any): any; }; }; percentage: { type: "number"; required: false; validator: { input: z.ZodOptional<z.ZodNumber>; output: z.ZodOptional<z.ZodNumber>; }; }; enabled: { type: "boolean"; defaultValue: true; }; createdAt: { type: "date"; required: true; defaultValue: () => Date; }; }; }; flagOverride: { modelName: string; fields: { flagId: { type: "string"; references: { model: string; field: string; onDelete: "cascade"; }; required: true; }; userId: { type: "string"; references: { model: string; field: string; onDelete: "cascade"; }; required: true; }; value: { type: "string"; required: true; transform: { input(value: any): string; output(value: any): any; }; }; enabled: { type: "boolean"; defaultValue: true; }; variant: { type: "string"; required: false; }; reason: { type: "string"; required: false; }; expiresAt: { type: "date"; required: false; }; createdAt: { type: "date"; required: true; defaultValue: () => Date; }; }; }; flagEvaluation: { modelName: string; fields: { flagId: { type: "string"; references: { model: string; field: string; onDelete: "cascade"; }; required: true; }; userId: { type: "string"; references: { model: string; field: string; onDelete: "set null"; }; required: false; }; sessionId: { type: "string"; required: false; }; value: { type: "string"; required: true; transform: { input(value: any): string; output(value: any): any; }; }; variant: { type: "string"; required: false; }; reason: { type: "string"; required: false; validator: { input: z.ZodOptional<z.ZodEnum<{ default: "default"; rule_match: "rule_match"; override: "override"; percentage_rollout: "percentage_rollout"; disabled: "disabled"; not_found: "not_found"; }>>; output: z.ZodOptional<z.ZodEnum<{ default: "default"; rule_match: "rule_match"; override: "override"; percentage_rollout: "percentage_rollout"; disabled: "disabled"; not_found: "not_found"; }>>; }; }; context: { type: "string"; required: false; transform: { input(value: any): string | null; output(value: any): any; }; }; evaluatedAt: { type: "date"; required: true; defaultValue: () => Date; }; }; }; flagAudit: { modelName: string; fields: { flagId: { type: "string"; references: { model: string; field: string; onDelete: "cascade"; }; required: true; }; userId: { type: "string"; references: { model: string; field: string; onDelete: "set null"; }; required: false; }; action: { type: "string"; required: true; validator: { input: 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"; }>; output: 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: { type: "string"; required: false; transform: { input(value: any): string | null; output(value: any): any; }; }; newValue: { type: "string"; required: false; transform: { input(value: any): string | null; output(value: any): any; }; }; metadata: { type: "string"; required: false; transform: { input(value: any): string | null; output(value: any): any; }; }; createdAt: { type: "date"; required: true; defaultValue: () => Date; }; }; }; }; init(auth: better_auth.AuthContext): {}; readonly hooks: { before: { matcher: (ctx: any) => any; handler: (ctx: any) => Promise<any>; }[]; after: { matcher: (ctx: any) => any; handler: (ctx: any) => Promise<any>; }[]; }; endpoints: { [key: string]: better_call.Endpoint; }; rateLimit: { window: number; max: number; pathMatcher: (path: string) => boolean; }[]; $ERROR_CODES: { FLAG_NOT_FOUND: string; INVALID_FLAG_TYPE: string; EVALUATION_ERROR: string; STORAGE_ERROR: string; UNAUTHORIZED_ACCESS: string; INVALID_CONTEXT: string; QUOTA_EXCEEDED: string; INVALID_INPUT: string; ADMIN_ACCESS_DISABLED: string; ORGANIZATION_REQUIRED: string; }; }; type FeatureFlagsServerPlugin = ReturnType<typeof createFeatureFlagsPlugin>; interface FeatureFlagVariant { /** Unique variant identifier */ key: string; /** Variant value of any type */ value: any; /** Rollout percentage weight (0-100) */ weight?: number; } interface FeatureFlagResult { /** Evaluated flag value */ value: any; /** Variant key returned by server */ variant?: string; /** Evaluation reason for debugging */ reason: "default" | "rule_match" | "override" | "percentage_rollout" | "not_found" | "disabled"; } /** * Feature flags client configuration options. * * @template Schema - Optional flag schema for type safety */ interface FeatureFlagsClientOptions<Schema extends Record<string, any> = Record<string, any>> { /** Client-side flag caching for performance and offline support */ cache?: { enabled?: boolean; /** TTL in ms - flags re-evaluate server-side after expiry */ ttl?: number; storage?: "memory" | "localStorage" | "sessionStorage"; keyPrefix?: string; /** Increment to bust cache across deployments */ version?: string; /** Maximum cache entries (default: 100) */ maxEntries?: number; /** Whitelist: only these flags are cached */ include?: string[]; /** Blacklist: these flags bypass cache (e.g., high-frequency A/B tests) */ exclude?: string[]; }; /** Smart polling with exponential backoff, prevents thundering herd */ polling?: { enabled?: boolean; /** Base interval in ms - backs off exponentially on errors */ interval?: number; }; /** Default flag values for fallback */ defaults?: Partial<Schema>; /** Enable debug logging */ debug?: boolean; /** Global error handler for flag evaluation failures */ onError?: (error: Error) => void; /** Called after each flag evaluation for analytics */ onEvaluation?: (flag: string, result: any) => void; /** Context sanitization - prevents PII leakage and enforces size limits */ contextSanitization?: { /** Default: true */ enabled?: boolean; /** Only allow whitelisted fields (default: true) */ strict?: boolean; /** Additional allowed fields beyond defaults */ allowedFields?: string[]; /** Max context size for GET requests (default: 2KB) */ maxUrlSize?: number; /** Max context size for POST requests (default: 10KB) */ maxBodySize?: number; /** Log warnings when fields are dropped */ warnOnDrop?: boolean; }; /** Override config for local testing - auto-disabled in production */ overrides?: OverrideConfig; } /** * Type-safe feature flags client interface. * * @template Schema - Optional flag schema for type safety * @example * interface MyFlags { * "feature.darkMode": boolean; * "experiment.algorithm": "A" | "B" | "C"; * } * const client: FeatureFlagsClient<MyFlags> = createAuthClient(); */ interface FeatureFlagsClient<Schema extends Record<string, any> = Record<string, any>> { featureFlags: { /** Check if boolean flag is enabled */ isEnabled: <K extends keyof Schema>(flag: K & { [P in K]: Schema[P] extends boolean ? K : never; }[K], defaultValue?: boolean) => Promise<boolean>; /** Get typed flag value with fallback */ getValue: <K extends keyof Schema>(flag: K, defaultValue?: Schema[K]) => Promise<Schema[K]>; /** Get variant key for A/B tests */ getVariant: <K extends keyof Schema>(flag: K) => Promise<string | null>; /** Evaluate single flag */ evaluate: <K extends keyof Schema>(flag: K, options?: { default?: Schema[K]; context?: EvaluationContext; environment?: string; select?: "value" | "full" | Array<"value" | "variant" | "reason" | "metadata">; contextInResponse?: boolean; }) => Promise<FeatureFlagResult>; /** Evaluate multiple flags efficiently */ evaluateMany: <K extends keyof Schema>(flags: K[], options?: { defaults?: Partial<Record<K, Schema[K]>>; context?: EvaluationContext; environment?: string; select?: "value" | "full" | Array<"value" | "variant" | "reason" | "metadata">; contextInResponse?: boolean; }) => Promise<Record<K, FeatureFlagResult>>; /** Bootstrap all flags for client */ bootstrap: (options?: { context?: EvaluationContext; environment?: string; include?: string[]; prefix?: string; select?: "value" | "full" | Array<"value" | "variant" | "reason" | "metadata">; contextInResponse?: boolean; }) => Promise<Partial<Schema>>; /** Track flag events for analytics */ track: <K extends keyof Schema>(flag: K, event: string, value?: number | Record<string, any>, options?: { idempotencyKey?: string; timestamp?: Date; sampleRate?: number; debug?: boolean; }) => Promise<{ success: boolean; eventId: string; sampled?: boolean; }>; /** Track multiple flag events in a single batch for efficiency */ trackBatch: <K extends keyof Schema>(events: Array<{ flag: K; event: string; data?: number | Record<string, any>; timestamp?: Date; sampleRate?: number; }>, options?: { idempotencyKey?: string; sampleRate?: number; }) => Promise<{ success: number; failed: number; sampled?: number; batchId: string; }>; /** Set evaluation context (session auto-managed) */ setContext: (context: Partial<EvaluationContext>) => void; /** Get current evaluation context */ getContext: () => EvaluationContext; /** Warm cache for known flags */ prefetch: <K extends keyof Schema>(flags: K[]) => Promise<void>; /** Clear all cached flags */ clearCache: () => void; /** Override flag value for local testing */ setOverride: <K extends keyof Schema>(flag: K, value: Schema[K]) => void; /** Clear all local overrides */ clearOverrides: () => void; /** Force refresh all flags from server */ refresh: () => Promise<void>; /** Subscribe to flag changes */ subscribe: (callback: (flags: Partial<Schema>) => void) => () => void; /** Cleanup resources and stop polling */ dispose?: () => void; admin: { /** Flags management */ flags: { list: (options?: { organizationId?: string; cursor?: string; limit?: number; q?: string; sort?: string; include?: "stats"; }) => Promise<{ flags: any[]; page: { nextCursor?: string; limit: number; hasMore: boolean; }; }>; create: (flag: { key: string; name: string; description?: string; enabled?: boolean; type: "string" | "number" | "boolean" | "json"; defaultValue: any; rolloutPercentage?: number; organizationId?: string; }) => Promise<any>; get: (id: string) => Promise<any>; update: (id: string, updates: { key?: string; name?: string; description?: string; enabled?: boolean; type?: "string" | "number" | "boolean" | "json"; defaultValue?: any; rolloutPercentage?: number; }) => Promise<any>; delete: (id: string) => Promise<{ success: boolean; }>; enable: (id: string) => Promise<any>; disable: (id: string) => Promise<any>; }; /** Rules management */ rules: { list: (flagId: string) => Promise<{ rules: any[]; }>; create: (rule: { flagId: string; priority: number; conditions: any; value: any; variant?: string; }) => Promise<any>; get: (flagId: string, ruleId: string) => Promise<any>; update: (flagId: string, ruleId: string, updates: any) => Promise<any>; delete: (flagId: string, ruleId: string) => Promise<any>; reorder: (flagId: string, ids: string[]) => Promise<any>; }; /** Overrides management */ overrides: { list: (options?: { organizationId?: string; cursor?: string; limit?: number; q?: string; sort?: string; flagId?: string; userId?: string; }) => Promise<{ overrides: any[]; page: { nextCursor?: string; limit: number; hasMore: boolean; }; }>; create: (override: { flagId: string; userId: string; value: any; enabled?: boolean; variant?: string; expiresAt?: string; }) => Promise<any>; get: (id: string) => Promise<any>; update: (id: string, updates: any) => Promise<any>; delete: (id: string) => Promise<any>; }; /** Analytics */ analytics: { stats: { get: (flagId: string, options?: { granularity?: "hour" | "day" | "week" | "month"; start?: string; end?: string; timezone?: string; }) => Promise<{ stats: any; }>; }; usage: { get: (options?: { start?: string; end?: string; timezone?: string; organizationId?: string; }) => Promise<{ usage: any; }>; }; }; /** Audit logs */ audit: { list: (options: { flagId?: string; userId?: string; action?: "create" | "update" | "delete" | "evaluate"; startDate?: string; endDate?: string; limit?: number; offset?: number; }) => Promise<{ entries: any[]; }>; get: (id: string) => Promise<any>; }; /** Environments */ environments: { list: () => Promise<{ environments: any[]; }>; create: (env: any) => Promise<any>; update: (id: string, updates: any) => Promise<any>; delete: (id: string) => Promise<any>; }; /** Data exports */ exports: { create: (options: any) => Promise<any>; }; }; }; } /** * Creates a type-safe feature flags client plugin for Better Auth. * * @template Schema - Optional flag schema for type safety * @see src/endpoints/ for server implementation */ declare function featureFlagsClient<Schema extends Record<string, any> = Record<string, any>>(options?: FeatureFlagsClientOptions<Schema>): { id: "feature-flags"; $InferServerPlugin: FeatureFlagsServerPlugin; pathMethods: { "/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"; "/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"; }; getAtoms: (..._args: any[]) => {}; getActions: (fetch: any, $store: any, _clientOptions: better_auth_client.ClientOptions | undefined) => { featureFlags: { isEnabled<K extends keyof Schema>(flag: K & { [P in K]: Schema[P] extends boolean ? K : never; }[K], defaultValue?: boolean): Promise<boolean>; getValue<K extends keyof Schema>(flag: K, defaultValue?: Schema[K]): Promise<Schema[K]>; getVariant<K extends keyof Schema>(flag: K): Promise<string | null>; evaluate<K extends keyof Schema>(flag: K, opts?: { default?: Schema[K]; context?: EvaluationContext; environment?: string; select?: "value" | "full" | Array<"value" | "variant" | "reason" | "metadata">; contextInResponse?: boolean; track?: boolean; debug?: boolean; }): Promise<FeatureFlagResult>; evaluateMany<K extends keyof Schema>(keys: K[], opts?: { defaults?: Partial<Record<K, Schema[K]>>; context?: EvaluationContext; environment?: string; select?: "value" | "full" | Array<"value" | "variant" | "reason" | "metadata">; contextInResponse?: boolean; track?: boolean; debug?: boolean; }): Promise<Record<K, FeatureFlagResult>>; bootstrap(options?: { context?: EvaluationContext; environment?: string; include?: string[]; prefix?: string; select?: "value" | "full" | Array<"value" | "variant" | "reason" | "metadata">; contextInResponse?: boolean; track?: boolean; debug?: boolean; defaults?: Partial<Schema>; }): Promise<Partial<Schema>>; track<K extends keyof Schema>(flag: K, event: string, value?: number | Record<string, any>, options?: { idempotencyKey?: string; timestamp?: Date; sampleRate?: number; debug?: boolean; }): Promise<{ success: boolean; eventId: string; sampled?: boolean; }>; trackBatch<K extends keyof Schema>(events: Array<{ flag: K; event: string; data?: number | Record<string, any>; timestamp?: Date; sampleRate?: number; }>, options?: { idempotencyKey?: string; sampleRate?: number; }): Promise<{ success: number; failed: number; sampled?: number; batchId: string; }>; setContext(newContext: Partial<EvaluationContext>): void; getContext(): EvaluationContext; prefetch<K extends keyof Schema>(flags: K[]): Promise<void>; clearCache(): void; setOverride<K extends keyof Schema>(flag: K, value: Schema[K]): void; clearOverrides(): void; refresh(): Promise<void>; subscribe(callback: (flags: Partial<Schema>) => void): () => void; admin: { flags: { list(options?: { organizationId?: string; cursor?: string; limit?: number; q?: string; sort?: string; include?: "stats"; }): Promise<{ flags: any[]; page: { nextCursor?: string; limit: number; hasMore: boolean; }; }>; create(flag: { key: string; name: string; description?: string; enabled?: boolean; type: "string" | "number" | "boolean" | "json"; defaultValue: any; rolloutPercentage?: number; organizationId?: string; }): Promise<any>; get(id: string): Promise<any>; update(id: string, updates: { key?: string; name?: string; description?: string; enabled?: boolean; type?: "string" | "number" | "boolean" | "json"; defaultValue?: any; rolloutPercentage?: number; }): Promise<any>; delete(id: string): Promise<{ success: boolean; }>; enable(id: string): Promise<any>; disable(id: string): Promise<any>; }; rules: { list(flagId: string): Promise<{ rules: any[]; }>; create(rule: { flagId: string; priority: number; conditions: any; value: any; variant?: string; }): Promise<any>; get(flagId: string, ruleId: string): Promise<any>; update(flagId: string, ruleId: string, updates: any): Promise<any>; delete(flagId: string, ruleId: string): Promise<any>; reorder(flagId: string, ids: string[]): Promise<any>; }; overrides: { list(options?: { organizationId?: string; cursor?: string; limit?: number; q?: string; sort?: string; flagId?: string; userId?: string; }): Promise<{ overrides: any[]; page: { nextCursor?: string; limit: number; hasMore: boolean; }; }>; create(override: { flagId: string; userId: string; value: any; enabled?: boolean; variant?: string; expiresAt?: string; }): Promise<any>; get(id: string): Promise<any>; update(id: string, updates: any): Promise<any>; delete(id: string): Promise<any>; }; analytics: { stats: { get(flagId: string, options?: { granularity?: "hour" | "day" | "week" | "month"; start?: string; end?: string; timezone?: string; }): Promise<{ stats: any; }>; }; usage: { get(options?: { start?: string; end?: string; timezone?: string; organizationId?: string; }): Promise<{ usage: any; }>; }; }; audit: { list(_options: { flagId?: string; userId?: string; action?: "create" | "update" | "delete" | "evaluate"; startDate?: string; endDate?: string; limit?: number; offset?: number; }): Promise<{ entries: any[]; }>; get(id: string): Promise<any>; }; environments: { list(): Promise<{ environments: any[]; }>; create(env: any): Promise<any>; update(id: string, updates: any): Promise<any>; delete(id: string): Promise<any>; }; exports: { create(options: any): Promise<any>; }; }; dispose(): void; }; }; }; export { EvaluationContext, type FeatureFlagResult, type FeatureFlagVariant, type FeatureFlagsClient, type FeatureFlagsClientOptions, featureFlagsClient as default, featureFlagsClient };