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,499 lines (1,377 loc) • 53.2 kB
text/typescript
// SPDX-FileCopyrightText: 2025-present Kriasoft
// SPDX-License-Identifier: MIT
import type { BetterAuthClientPlugin } from "better-auth/client";
import { FlagCache } from "./client/cache";
import { ContextSanitizer } from "./context-sanitizer";
import { SecureOverrideManager, type OverrideConfig } from "./override-manager";
import type { FeatureFlagsServerPlugin } from "./plugin";
import { SmartPoller } from "./polling";
export type { EvaluationContext } from "./schema/types";
export type { BooleanFlags, InferFlagValue, ValidateFlagSchema } from "./types";
export interface FeatureFlagVariant {
/** Unique variant identifier */
key: string;
/** Variant value of any type */
value: any;
/** Rollout percentage weight (0-100) */
weight?: number;
}
export 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
*/
export 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;
}
import type { EvaluationContext } from "./schema/types";
/**
* 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();
*/
export 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>;
// === CANONICAL PUBLIC API ===
/** 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 NAMESPACE (canonical per API spec) ===
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; // e.g., "-createdAt"
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>;
};
};
};
}
// REF: ./client/cache.ts for FlagCache implementation
/**
* 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
*/
export function featureFlagsClient<
Schema extends Record<string, any> = Record<string, any>,
>(options: FeatureFlagsClientOptions<Schema> = {}) {
const cache = new FlagCache(options.cache);
const overrideManager = new SecureOverrideManager(options.overrides);
const subscribers = new Set<(flags: Partial<Schema>) => void>();
let context: EvaluationContext = {};
let cachedFlags: Partial<Schema> = {};
let smartPoller: SmartPoller | null = null;
let sessionUnsubscribe: (() => void) | null = null;
let lastSessionId: string | undefined = undefined;
// Context sanitization prevents PII leakage (see: src/context-sanitizer.ts)
const sanitizer = new ContextSanitizer({
strict: options.contextSanitization?.strict ?? true,
allowedFields: options.contextSanitization?.allowedFields
? new Set(options.contextSanitization.allowedFields)
: undefined,
maxSizeForUrl: options.contextSanitization?.maxUrlSize,
maxSizeForBody: options.contextSanitization?.maxBodySize,
warnOnDrop: options.contextSanitization?.warnOnDrop,
});
const sanitizationEnabled = options.contextSanitization?.enabled ?? true;
const notifySubscribers = (flags: Partial<Schema>) => {
cachedFlags = flags;
subscribers.forEach((callback) => callback(flags));
};
return {
id: "feature-flags",
$InferServerPlugin: {} as FeatureFlagsServerPlugin,
// 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: (..._args: any[]) => ({}),
getActions: (
fetch: any,
$store: any,
_clientOptions: import("better-auth/client").ClientOptions | undefined,
) => {
// Session subscription invalidates cache on user change
if ($store?.atoms?.session) {
const unsubscribe = $store.atoms.session.subscribe(
(sessionState: any) => {
const currentSessionId = sessionState?.data?.session?.id;
if (currentSessionId !== lastSessionId) {
lastSessionId = currentSessionId;
// Prevent cross-user flag contamination
cache.invalidateOnSessionChange(currentSessionId);
cachedFlags = {};
// Refresh flags for new authenticated sessions
if (currentSessionId) {
notifySubscribers({});
}
}
},
);
sessionUnsubscribe = unsubscribe;
}
const handleError = (error: Error) => {
if (options.debug) {
console.error("[feature-flags]", error);
}
options.onError?.(error);
};
const logEvaluation = (flag: string, result: any) => {
if (options.debug) {
console.log(`[feature-flags] ${flag}:`, result);
}
options.onEvaluation?.(flag, result);
};
const evaluateFlag = async (
key: keyof Schema | string,
evaluateOptions?: {
track?: boolean;
select?:
| "value"
| "full"
| Array<"value" | "variant" | "reason" | "metadata">;
debug?: boolean;
contextInResponse?: boolean;
},
): Promise<FeatureFlagResult> => {
// Priority: override > cache > server evaluation
const overrideValue = overrideManager.get(String(key));
if (overrideValue !== undefined) {
return {
value: overrideValue,
reason: "override",
};
}
const cached = cache.get(String(key));
if (cached !== undefined) {
logEvaluation(String(key), cached);
return cached; // Cache hit - no network call
}
try {
const keyStr = String(key);
// Better Auth fetch includes auth headers automatically
const requestBody: any = {
flagKey: keyStr,
context:
Object.keys(context).length > 0
? sanitizationEnabled
? sanitizer.sanitizeForBody(context)
: context
: undefined,
default: options.defaults?.[key as keyof Schema],
};
// Add optional parameters if provided
if (evaluateOptions?.track !== undefined) {
requestBody.track = evaluateOptions.track;
}
if (evaluateOptions?.select !== undefined) {
requestBody.select = evaluateOptions.select;
}
if (evaluateOptions?.debug !== undefined) {
requestBody.debug = evaluateOptions.debug;
}
if (evaluateOptions?.contextInResponse !== undefined) {
requestBody.contextInResponse = evaluateOptions.contextInResponse;
}
const response = await fetch(`/feature-flags/evaluate`, {
method: "POST",
body: requestBody,
});
const result = response.data as FeatureFlagResult;
cache.set(keyStr, result);
logEvaluation(keyStr, result);
return result;
} catch (error) {
handleError(error as Error);
// Graceful degradation during server outages
if (options.defaults?.[key as keyof Schema] !== undefined) {
return {
value: options.defaults[key as keyof Schema],
reason: "default",
};
}
return {
value: undefined,
reason: "not_found",
};
}
};
const actions = {
featureFlags: {
async isEnabled<K extends keyof Schema>(
flag: K & { [P in K]: Schema[P] extends boolean ? K : never }[K],
defaultValue = false,
): Promise<boolean> {
const result = await evaluateFlag(flag);
const value = result.value ?? defaultValue;
return Boolean(value);
},
async getValue<K extends keyof Schema>(
flag: K,
defaultValue?: Schema[K],
): Promise<Schema[K]> {
const result = await evaluateFlag(flag);
return result.value ?? defaultValue;
},
async getVariant<K extends keyof Schema>(
flag: K,
): Promise<string | null> {
const result = await evaluateFlag(flag);
return result.variant || null;
},
// Core evaluation methods
async 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> {
// Back-compat: if previous signature used, opts may be default value
let defaultValue: any = undefined;
let environment: string | undefined = undefined;
// Context override is currently not applied; use setContext() instead
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 as any).default;
environment = (opts as any).environment;
}
// Temporarily enrich context with environment for this call
const originalContext = { ...context };
if (environment) {
const next = { ...context } as any;
next.attributes = { ...(next.attributes || {}), environment };
context = next;
}
try {
const result = await evaluateFlag(flag, opts);
return defaultValue !== undefined && result.value === undefined
? { ...result, value: defaultValue }
: result;
} finally {
// Restore context
context = originalContext;
}
},
async 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>> {
// Batch optimization: 1 network call for N flags, cache-aware
const cachedResults = cache.getMany(keys.map(String));
const results: Record<K, FeatureFlagResult> = {} as Record<
K,
FeatureFlagResult
>;
const uncachedKeys: string[] = [];
const defaultsMap = (opts?.defaults || {}) as Record<string, any>;
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));
}
}
// Skip network if all flags cached
if (uncachedKeys.length === 0) {
return results;
}
// Fetch only uncached flags
try {
// Optionally enrich context with environment for this call
const originalContext = { ...context } as any;
if (opts?.environment) {
const next = { ...context } as any;
next.attributes = {
...(next.attributes || {}),
environment: opts.environment,
};
context = next;
}
const batchRequestBody: any = {
flagKeys: uncachedKeys,
defaults:
Object.keys(defaultsMap).length > 0
? Object.fromEntries(
uncachedKeys
.filter((k) => defaultsMap[k] !== undefined)
.map((k) => [k, defaultsMap[k]]),
)
: undefined,
context:
Object.keys(context).length > 0
? sanitizationEnabled
? sanitizer.sanitizeForBody(context)
: context
: undefined,
// Do not pass select='value' from client to keep return type stable
// environment support via context enrichment below
};
// Add optional parameters if provided
if (opts?.track !== undefined) {
batchRequestBody.track = opts.track;
}
if (opts?.select !== undefined) {
batchRequestBody.select = opts.select;
}
if (opts?.debug !== undefined) {
batchRequestBody.debug = opts.debug;
}
if (opts?.contextInResponse !== undefined) {
batchRequestBody.contextInResponse = opts.contextInResponse;
}
const response = await fetch("/feature-flags/evaluate-batch", {
method: "POST",
body: batchRequestBody,
});
// Restore context after preparing body
context = originalContext;
const data = response.data as {
flags: Record<string, FeatureFlagResult>;
evaluatedAt?: string; // ISO string when received over HTTP
};
// Batch cache update
cache.setMany(data.flags);
// Merge server results with cache
for (const [key, result] of Object.entries(data.flags)) {
(results as any)[key] = result as FeatureFlagResult;
logEvaluation(key, result);
}
return results;
} catch (error) {
handleError(error as Error);
// Fallback to defaults on failure
for (const key of uncachedKeys) {
results[key as K] = {
value: defaultsMap[key],
reason: "default",
} as FeatureFlagResult as any;
}
return results;
}
},
async 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>> {
try {
const bootstrapRequestBody: any = {
include: options?.include,
prefix: options?.prefix,
environment: options?.environment,
context:
Object.keys(context).length > 0
? sanitizationEnabled
? sanitizer.sanitizeForBody(context)
: context
: undefined,
};
// Add optional parameters if provided
if (options?.select !== undefined) {
bootstrapRequestBody.select = options.select;
}
if (options?.track !== undefined) {
bootstrapRequestBody.track = options.track;
}
if (options?.debug !== undefined) {
bootstrapRequestBody.debug = options.debug;
}
if (options?.contextInResponse !== undefined) {
bootstrapRequestBody.contextInResponse =
options.contextInResponse;
}
const response = await fetch(`/feature-flags/bootstrap`, {
method: "POST",
body: bootstrapRequestBody,
});
const data = response.data as {
flags: Record<string, FeatureFlagResult>;
};
const flags: Partial<Schema> = {};
for (const [key, result] of Object.entries(data.flags)) {
(flags as any)[key] = result.value;
// Cache bootstrap results individually
cache.set(key, result);
}
notifySubscribers(flags);
return flags;
} catch (error) {
handleError(error as Error);
return options?.defaults || {};
}
},
async 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 }> {
try {
// CLIENT-SIDE SAMPLING: Skip network call if sampled out
if (
options?.sampleRate !== undefined &&
typeof options.sampleRate === "number"
) {
if (options.sampleRate < 0 || options.sampleRate > 1) {
throw new Error("sampleRate must be between 0 and 1");
}
// Probabilistic sampling: skip if random value exceeds sample rate
if (Math.random() > options.sampleRate) {
if (options.debug) {
console.log(
`[feature-flags] Event sampled out (rate: ${options.sampleRate})`,
);
}
return {
success: true,
eventId: "sampled_out",
sampled: true,
};
}
}
const headers: Record<string, string> = {};
if (options?.idempotencyKey) {
headers["Idempotency-Key"] = options.idempotencyKey;
}
const response = await fetch("/feature-flags/events", {
method: "POST",
headers,
body: {
flagKey: String(flag),
event: event,
properties: value,
timestamp: options?.timestamp,
sampleRate: options?.sampleRate,
},
});
return response.data;
} catch (error) {
handleError(error as Error);
return { success: false, eventId: "" };
}
},
async 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;
}> {
try {
// CLIENT-SIDE SAMPLING: Filter out events based on sampling
const filteredEvents = [];
let sampledCount = 0;
for (const eventData of events) {
const eventSampleRate =
eventData.sampleRate ?? options?.sampleRate;
if (
eventSampleRate !== undefined &&
typeof eventSampleRate === "number"
) {
if (eventSampleRate < 0 || eventSampleRate > 1) {
continue; // Skip invalid sample rates
}
// Probabilistic sampling: skip if random value exceeds sample rate
if (Math.random() > eventSampleRate) {
sampledCount++;
continue;
}
}
filteredEvents.push(eventData);
}
// If all events were sampled out, return early
if (filteredEvents.length === 0) {
return {
success: 0,
failed: 0,
sampled: sampledCount,
batchId: "sampled_out",
};
}
// Transform for server schema
const transformedEvents = filteredEvents.map(
({ flag, event, data, timestamp, sampleRate }) => ({
flagKey: String(flag),
event: event,
properties: data,
timestamp,
sampleRate,
}),
);
const response = await fetch("/feature-flags/events/batch", {
method: "POST",
body: {
events: transformedEvents,
sampleRate: options?.sampleRate,
idempotencyKey: options?.idempotencyKey,
},
});
const result = response.data;
return {
...result,
sampled: sampledCount + (result.sampled || 0),
};
} catch (error) {
handleError(error as Error);
// Return failure metrics
return {
success: 0,
failed: events.length,
sampled: 0,
batchId: options?.idempotencyKey || "",
};
}
},
setContext(newContext: Partial<EvaluationContext>): void {
// Validate context in debug mode (security)
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 };
// Context change invalidates cache (rules may differ)
cache.clear();
},
getContext(): EvaluationContext {
return { ...context };
},
async prefetch<K extends keyof Schema>(flags: K[]): Promise<void> {
// Warm cache for route changes
const uncached = flags.filter(
(key) => cache.get(String(key)) === undefined,
);
if (uncached.length > 0) {
await actions.featureFlags.evaluateMany(uncached as K[]);
}
},
clearCache(): void {
cache.clear();
},
setOverride<K extends keyof Schema>(flag: K, value: Schema[K]): void {
const success = overrideManager.set(String(flag), value);
if (success) {
// Notify subscribers of local override
notifySubscribers({ ...cachedFlags, [flag]: value });
}
},
clearOverrides(): void {
overrideManager.clear();
// Refresh with real server values
actions.featureFlags.refresh();
},
async refresh(): Promise<void> {
cache.clear();
const flags = await actions.featureFlags.bootstrap();
notifySubscribers(flags);
},
subscribe(callback: (flags: Partial<Schema>) => void): () => void {
subscribers.add(callback);
// Immediate callback with current flags
callback(cachedFlags);
return () => {
subscribers.delete(callback);
};
},
// Admin API methods
admin: {
// Flag CRUD operations
flags: {
async list(options?: {
organizationId?: string;
cursor?: string;
limit?: number;
q?: string;
sort?: string;
include?: "stats";
}): Promise<{
flags: any[];
page: { nextCursor?: string; limit: number; hasMore: boolean };
}> {
try {
const response = await fetch("/feature-flags/admin/flags", {
method: "GET",
query: options,
});
return response.data;
} catch (error) {
handleError(error as Error);
return { flags: [], page: { limit: 50, hasMore: false } };
}
},
async create(flag: {
key: string;
name: string;
description?: string;
enabled?: boolean;
type: "string" | "number" | "boolean" | "json";
defaultValue: any;
rolloutPercentage?: number;
organizationId?: string;
}): Promise<any> {
try {
const response = await fetch("/feature-flags/admin/flags", {
method: "POST",
body: flag,
});
return response.data;
} catch (error) {
handleError(error as Error);
throw error;
}
},
async get(id: string): Promise<any> {
try {
const response = await fetch(
`/feature-flags/admin/flags/${id}`,
{
method: "GET",
},
);
return response.data;
} catch (error) {
handleError(error as Error);
throw error;
}
},
async update(
id: string,
updates: {
key?: string;
name?: string;
description?: string;
enabled?: boolean;
type?: "string" | "number" | "boolean" | "json";
defaultValue?: any;
rolloutPercentage?: number;
},
): Promise<any> {
try {
const response = await fetch(
`/feature-flags/admin/flags/${id}`,
{
method: "PATCH",
body: updates,
},
);
return response.data;
} catch (error) {
handleError(error as Error);
throw error;
}
},
async delete(id: string): Promise<{ success: boolean }> {
try {
const response = await fetch(
`/feature-flags/admin/flags/${id}`,
{
method: "DELETE",
},
);
return response.data;
} catch (error) {
handleError(error as Error);
throw error;
}
},
async enable(id: string): Promise<any> {
try {
const response = await fetch(
`/feature-flags/admin/flags/${id}/enable`,
{
method: "POST",
},
);
return response.data;
} catch (error) {
handleError(error as Error);
throw error;
}
},
async disable(id: string): Promise<any> {
try {
const response = await fetch(
`/feature-flags/admin/flags/${id}/disable`,
{
method: "POST",
},
);
return response.data;
} catch (error) {
handleError(error as Error);
throw error;
}
},
},
// Rule CRUD operations
rules: {
async list(flagId: string): Promise<{ rules: any[] }> {
try {
const response = await fetch(
`/feature-flags/admin/flags/${flagId}/rules`,
{
method: "GET",
},
);
return response.data;
} catch (error) {
handleError(error as Error);
return { rules: [] };
}
},
async create(rule: {
flagId: string;
priority: number;
conditions: any;
value: any;
variant?: string;
}): Promise<any> {
try {
const response = await fetch(
`/feature-flags/admin/flags/${rule.flagId}/rules`,
{
method: "POST",
body: rule,
},
);
return response.data;
} catch (error) {
handleError(error as Error);
throw error;
}
},
async get(flagId: string, ruleId: string): Promise<any> {
try {
const response = await fetch(
`/feature-flags/admin/flags/${flagId}/rules/${ruleId}`,
{
method: "GET",
},
);
return response.data;
} catch (error) {
handleError(error as Error);
throw error;
}
},
async update(
flagId: string,
ruleId: string,
updates: any,
): Promise<any> {
try {
const response = await fetch(
`/feature-flags/admin/flags/${flagId}/rules/${ruleId}`,
{
method: "PATCH",
body: updates,
},
);
return response.data;
} catch (error) {
handleError(error as Error);
throw error;
}
},
async delete(flagId: string, ruleId: string): Promise<any> {
try {
const response = await fetch(
`/feature-flags/admin/flags/${flagId}/rules/${ruleId}`,
{
method: "DELETE",
},
);
return response.data;
} catch (error) {
handleError(error as Error);
throw error;
}
},
async reorder(flagId: string, ids: string[]): Promise<any> {
try {
const response = await fetch(
`/feature-flags/admin/flags/${flagId}/rules/reorder`,
{
method: "POST",
body: { ids },
},
);
return response.data;
} catch (error) {
handleError(error as Error);
throw error;
}
},
},
// Override CRUD operations
overrides: {
async list(options?: {
organizationId?: string;
cursor?: string;
limit?: number;
q?: string;
sort?: string; // e.g., "-createdAt"
flagId?: string;
userId?: string;
}): Promise<{
overrides: any[];
page: { nextCursor?: string; limit: number; hasMore: boolean };
}> {
try {
const response = await fetch(
"/feature-flags/admin/overrides",
{
method: "GET",
query: options,
},
);
return response.data;
} catch (error) {
handleError(error as Error);
return { overrides: [], page: { limit: 50, hasMore: false } };
}
},
async create(override: {
flagId: string;
userId: string;
value: any;
enabled?: boolean;
variant?: string;
expiresAt?: string;
}): Promise<any> {
try {
const response = await fetch(
"/feature-flags/admin/overrides",
{
method: "POST",
body: override,
},
);
return response.data;
} catch (error) {
handleError(error as Error);
throw error;
}
},
async get(id: string): Promise<any> {
try {
const response = await fetch(
`/feature-flags/admin/overrides/${id}`,
{
method: "GET",
},
);
return response.data;
} catch (error) {
handleError(error as Error);
throw error;
}
},
async update(id: string, updates: any): Promise<any> {
try {
const response = await fetch(
`/feature-flags/admin/overrides/${id}`,
{
method: "PATCH",
body: updates,
},
);
return response.data;
} catch (error) {
handleError(error as Error);
throw error;
}
},
async delete(id: string): Promise<any> {
try {
const response = await fetch(
`/feature-flags/admin/overrides/${id}`,
{
method: "DELETE",
},
);
return response.data;
} catch (error) {
handleError(error as Error);
throw error;
}
},
},
// Analytics
analytics: {
stats: {
async get(
flagId: string,
options: {
granularity?: "hour" | "day" | "week" | "month";
start?: string;
end?: string;
timezone?: string;
} = {},
): Promise<{ stats: any }> {
try {
const response = await fetch(
`/feature-flags/admin/flags/${flagId}/stats`,
{
method: "GET",
query: options,
},
);
return response.data;
} catch (error) {
handleError(error as Error);
return { stats: {} };
}
},
},
usage: {
async get(
options: {
start?: string;
end?: string;
timezone?: string;
organizationId?: string;
} = {},
): Promise<{ usage: any }> {
try {
const response = await fetch(
"/feature-flags/admin/metrics/usage",
{
method: "GET",
query: options,
},
);
return response.data;
} catch (error) {
handleError(error as Error);
return { usage: {} };
}
},
},
},
// Audit logs
audit: {
async list(_options: {
flagId?: string;
userId?: string;
action?: "create" | "update" | "delete" | "evaluate";
startDate?: string;
endDate?: string;
limit?: number;
offset?: number;
}): Promise<{ entries: any[] }> {
try {
const response = await fetch("/feature-flags/admin/audit", {
method: "GET",
});
return response.data;
} catch (error) {
handleError(error as Error);
return { entries: [] };
}
},
async get(id: string): Promise<any> {
try {
const response = await fetch(
`/feature-flags/admin/audit/${id}`,
{
method: "GET",
},
);
return response.data;
} catch (error) {
handleError(error as Error);
throw error;
}
},
},
// Environments
environments: {
async list(): Promise<{ environments: any[] }> {
try {
const response = await fetch(
"/feature-flags/admin/environments",
{
method: "GET",
},
);
return response.data;
} catch (error) {
handleError(error as Error);
return { environments: [] };
}
},
async create(env: any): Promise<any> {
try {
const response = await fetch(
"/feature-flags/admin