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,680 lines (1,670 loc) 65.5 kB
import { evaluateFlags, evaluateFlagsBatch, generateId, parseJSON } from "./chunk-IYGCCL4V.js"; import { DEFAULT_HEADER_CONFIG, buildEvaluationContext, createMiddleware } from "./chunk-YSWZCOTI.js"; import { __name } from "./chunk-SHUYVCID.js"; // src/schema/tables.ts import * as z from "zod"; var featureFlagsSchema = { featureFlag: { modelName: "featureFlag", fields: { key: { type: "string", required: true, unique: true }, name: { type: "string", required: true }, description: { type: "string", required: false }, type: { type: "string", defaultValue: "boolean", validator: { input: z.enum(["boolean", "string", "number", "json"]), output: z.enum(["boolean", "string", "number", "json"]) } }, enabled: { type: "boolean", defaultValue: false }, defaultValue: { type: "string", required: false, transform: { input(value) { return value !== void 0 ? JSON.stringify(value) : null; }, output(value) { if (!value) return null; return parseJSON(value); } } /** @decision Type validation at API layer to avoid circular schema dependency */ }, rolloutPercentage: { type: "number", defaultValue: 0, validator: { input: z.number().min(0).max(100), output: z.number().min(0).max(100) } }, organizationId: { type: "string", references: { model: "organization", field: "id", onDelete: "cascade" }, required: false }, createdAt: { type: "date", required: true, defaultValue: /* @__PURE__ */ __name(() => /* @__PURE__ */ new Date(), "defaultValue") }, updatedAt: { type: "date", required: true, defaultValue: /* @__PURE__ */ __name(() => /* @__PURE__ */ new Date(), "defaultValue") }, variants: { type: "string", required: false, transform: { input(value) { return value ? JSON.stringify(value) : null; }, output(value) { if (!value) return null; return parseJSON(value); } } }, metadata: { type: "string", required: false, transform: { input(value) { return value ? JSON.stringify(value) : null; }, output(value) { if (!value) return null; return parseJSON(value); } } } } }, flagRule: { modelName: "flagRule", fields: { flagId: { type: "string", references: { model: "featureFlag", field: "id", onDelete: "cascade" }, required: true }, priority: { type: "number", defaultValue: 0, validator: { input: z.number().int().min(-1e3).max(1e3), output: z.number().int() } }, name: { type: "string", required: false }, conditions: { type: "string", required: true, transform: { input(value) { return JSON.stringify(value); }, output(value) { return parseJSON(value); } } }, value: { type: "string", required: true, transform: { input(value) { return JSON.stringify(value); }, output(value) { return parseJSON(value); } } }, percentage: { type: "number", required: false, validator: { input: z.number().min(0).max(100).optional(), output: z.number().min(0).max(100).optional() } }, enabled: { type: "boolean", defaultValue: true }, createdAt: { type: "date", required: true, defaultValue: /* @__PURE__ */ __name(() => /* @__PURE__ */ new Date(), "defaultValue") } } }, flagOverride: { modelName: "flagOverride", /** @invariant One override per (flag, user) pair - enforced by uk_flag_user constraint */ fields: { flagId: { type: "string", references: { model: "featureFlag", field: "id", onDelete: "cascade" }, required: true }, userId: { type: "string", references: { model: "user", field: "id", onDelete: "cascade" }, required: true }, value: { type: "string", required: true, transform: { input(value) { return JSON.stringify(value); }, output(value) { return parseJSON(value); } } }, reason: { type: "string", required: false }, expiresAt: { type: "date", required: false }, createdAt: { type: "date", required: true, defaultValue: /* @__PURE__ */ __name(() => /* @__PURE__ */ new Date(), "defaultValue") } } }, flagEvaluation: { modelName: "flagEvaluation", fields: { flagId: { type: "string", references: { model: "featureFlag", field: "id", onDelete: "cascade" }, required: true }, userId: { type: "string", references: { model: "user", field: "id", onDelete: "set null" }, required: false }, sessionId: { type: "string", required: false }, value: { type: "string", required: true, transform: { input(value) { return JSON.stringify(value); }, output(value) { return parseJSON(value); } } }, variant: { type: "string", required: false }, reason: { type: "string", required: false, validator: { input: z.enum([ "rule_match", "override", "percentage_rollout", "default", "disabled", "not_found" ]).optional(), output: z.enum([ "rule_match", "override", "percentage_rollout", "default", "disabled", "not_found" ]).optional() } }, context: { type: "string", required: false, transform: { input(value) { return value ? JSON.stringify(value) : null; }, output(value) { if (!value) return null; return parseJSON(value); } } }, evaluatedAt: { type: "date", required: true, defaultValue: /* @__PURE__ */ __name(() => /* @__PURE__ */ new Date(), "defaultValue") } } }, flagAudit: { modelName: "flagAudit", fields: { flagId: { type: "string", references: { model: "featureFlag", field: "id", onDelete: "cascade" }, required: true }, userId: { type: "string", references: { model: "user", field: "id", onDelete: "set null" }, required: false }, action: { type: "string", required: true, validator: { input: z.enum([ "created", "updated", "deleted", "enabled", "disabled", "rule_added", "rule_updated", "rule_deleted", "override_added", "override_removed" ]), output: z.enum([ "created", "updated", "deleted", "enabled", "disabled", "rule_added", "rule_updated", "rule_deleted", "override_added", "override_removed" ]) } }, previousValue: { type: "string", required: false, transform: { input(value) { return value ? JSON.stringify(value) : null; }, output(value) { if (!value) return null; return parseJSON(value); } } }, newValue: { type: "string", required: false, transform: { input(value) { return value ? JSON.stringify(value) : null; }, output(value) { if (!value) return null; return parseJSON(value); } } }, metadata: { type: "string", required: false, transform: { input(value) { return value ? JSON.stringify(value) : null; }, output(value) { if (!value) return null; return parseJSON(value); } } }, createdAt: { type: "date", required: true, defaultValue: /* @__PURE__ */ __name(() => /* @__PURE__ */ new Date(), "defaultValue") } } } }; // src/schema/validation.ts import * as z2 from "zod"; var flagTypeSchema = z2.enum(["boolean", "string", "number", "json"]); var evaluationReasonSchema = z2.enum([ "rule_match", "override", "percentage_rollout", "default", "disabled", "not_found" ]); var auditActionSchema = z2.enum([ "created", "updated", "deleted", "enabled", "disabled", "rule_added", "rule_updated", "rule_deleted", "override_added", "override_removed" ]); var conditionOperatorSchema = z2.enum([ "equals", "not_equals", "contains", "not_contains", "starts_with", "ends_with", "greater_than", "less_than", "greater_than_or_equal", "less_than_or_equal", "in", "not_in", "regex" ]); var conditionSchema = z2.object({ attribute: z2.string(), operator: conditionOperatorSchema, value: z2.any() }); var ruleConditionsSchema = z2.object({ all: z2.array(conditionSchema).optional(), any: z2.array(conditionSchema).optional(), not: z2.lazy(() => ruleConditionsSchema).optional() }); var flagRuleInputSchema = z2.object({ name: z2.string().optional(), priority: z2.number().default(0), conditions: ruleConditionsSchema, value: z2.any(), percentage: z2.number().min(0).max(100).optional(), enabled: z2.boolean().default(true) }); var variantSchema = z2.object({ key: z2.string(), value: z2.any(), weight: z2.number().min(0).max(100), // Distribution weight, must sum to 100 metadata: z2.record(z2.string(), z2.any()).optional() }); var featureFlagInputSchema = z2.object({ key: z2.string().regex(/^[a-z0-9-_]+$/i, { message: "Key must contain only alphanumeric characters, hyphens, and underscores" }), name: z2.string(), description: z2.string().optional(), type: flagTypeSchema.default("boolean"), enabled: z2.boolean().default(false), defaultValue: z2.any().optional(), rolloutPercentage: z2.number().min(0).max(100).default(0), variants: z2.array(variantSchema).optional().refine( (variants) => { if (!variants || variants.length === 0) return true; const totalWeight = variants.reduce((sum, v) => sum + v.weight, 0); return Math.abs(totalWeight - 100) < 0.01; }, { message: "Variant weights must sum to 100" } ), metadata: z2.record(z2.string(), z2.any()).optional() }); var evaluationContextSchema = z2.object({ userId: z2.string().optional(), email: z2.string().email("Invalid email address").optional(), role: z2.string().optional(), organizationId: z2.string().optional(), attributes: z2.record(z2.string(), z2.any()).optional() }); var flagOverrideInputSchema = z2.object({ flagId: z2.string(), userId: z2.string(), value: z2.any(), reason: z2.string().optional(), expiresAt: z2.date().optional() }); var flagOverrideUpsertSchema = flagOverrideInputSchema.extend({ id: z2.string().optional() // Include if updating existing }); var flagEvaluationInputSchema = z2.object({ flagId: z2.string(), userId: z2.string().optional(), sessionId: z2.string().optional(), value: z2.any(), variant: z2.string().optional(), reason: evaluationReasonSchema.optional(), context: z2.record(z2.string(), z2.any()).optional() }); var flagAuditInputSchema = z2.object({ flagId: z2.string(), userId: z2.string().optional(), action: auditActionSchema, previousValue: z2.any().optional(), newValue: z2.any().optional(), metadata: z2.record(z2.string(), z2.any()).optional() }); // src/storage/memory.ts var MemoryStorage = class { // flagId:userId -> overrideId constructor(config) { this.config = config; this.flags = /* @__PURE__ */ new Map(); this.rules = /* @__PURE__ */ new Map(); this.overrides = /* @__PURE__ */ new Map(); this.evaluations = /* @__PURE__ */ new Map(); this.auditLogs = /* @__PURE__ */ new Map(); this.flagKeyIndex = /* @__PURE__ */ new Map(); // key -> id mapping this.flagRulesIndex = /* @__PURE__ */ new Map(); // flagId -> ruleIds this.overrideIndex = /* @__PURE__ */ new Map(); } static { __name(this, "MemoryStorage"); } async initialize() { } async close() { this.flags.clear(); this.rules.clear(); this.overrides.clear(); this.evaluations.clear(); this.auditLogs.clear(); this.flagKeyIndex.clear(); this.flagRulesIndex.clear(); this.overrideIndex.clear(); } // Flag operations async createFlag(flag) { const id = generateId(); const now = /* @__PURE__ */ new Date(); const newFlag = { ...flag, id, createdAt: now, updatedAt: now }; this.flags.set(id, newFlag); const indexKey = this.getFlagIndexKey(flag.key, flag.organizationId); this.flagKeyIndex.set(indexKey, id); return newFlag; } async getFlag(key, organizationId) { const indexKey = this.getFlagIndexKey(key, organizationId); const id = this.flagKeyIndex.get(indexKey); return id ? this.flags.get(id) || null : null; } async getFlagById(id) { return this.flags.get(id) || null; } async listFlags(organizationId, options) { let flags = Array.from(this.flags.values()); if (organizationId) { flags = flags.filter((f) => f.organizationId === organizationId); } if (options?.filter) { flags = flags.filter((flag) => { return Object.entries(options.filter).every(([key, value]) => { return flag[key] === value; }); }); } if (options?.orderBy) { flags.sort((a, b) => { const aVal = a[options.orderBy]; const bVal = b[options.orderBy]; const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; return options.orderDirection === "desc" ? -comparison : comparison; }); } const offset = options?.offset || 0; const limit = options?.limit || flags.length; return flags.slice(offset, offset + limit); } async updateFlag(id, updates) { const flag = this.flags.get(id); if (!flag) { throw new Error(`Flag not found: ${id}`); } const updatedFlag = { ...flag, ...updates, id: flag.id, // Ensure ID doesn't change createdAt: flag.createdAt, // Ensure createdAt doesn't change updatedAt: /* @__PURE__ */ new Date() }; this.flags.set(id, updatedFlag); if (updates.key && updates.key !== flag.key) { const oldIndexKey = this.getFlagIndexKey(flag.key, flag.organizationId); const newIndexKey = this.getFlagIndexKey( updates.key, flag.organizationId ); this.flagKeyIndex.delete(oldIndexKey); this.flagKeyIndex.set(newIndexKey, id); } return updatedFlag; } async deleteFlag(id) { const flag = this.flags.get(id); if (flag) { const indexKey = this.getFlagIndexKey(flag.key, flag.organizationId); this.flagKeyIndex.delete(indexKey); this.flags.delete(id); const ruleIds = this.flagRulesIndex.get(id); if (ruleIds) { for (const ruleId of ruleIds) { this.rules.delete(ruleId); } this.flagRulesIndex.delete(id); } for (const [key, overrideId] of this.overrideIndex.entries()) { if (key.startsWith(`${id}:`)) { this.overrides.delete(overrideId); this.overrideIndex.delete(key); } } } } // Rule operations async createRule(rule) { const id = generateId(); const newRule = { ...rule, id, createdAt: /* @__PURE__ */ new Date() }; this.rules.set(id, newRule); if (!this.flagRulesIndex.has(rule.flagId)) { this.flagRulesIndex.set(rule.flagId, /* @__PURE__ */ new Set()); } this.flagRulesIndex.get(rule.flagId).add(id); return newRule; } async getRulesForFlag(flagId) { const ruleIds = this.flagRulesIndex.get(flagId); if (!ruleIds) return []; const rules = []; for (const ruleId of ruleIds) { const rule = this.rules.get(ruleId); if (rule) rules.push(rule); } return rules.sort((a, b) => a.priority - b.priority); } async updateRule(id, updates) { const rule = this.rules.get(id); if (!rule) { throw new Error(`Rule not found: ${id}`); } const updatedRule = { ...rule, ...updates, id: rule.id, createdAt: rule.createdAt }; this.rules.set(id, updatedRule); return updatedRule; } async deleteRule(id) { const rule = this.rules.get(id); if (rule) { this.rules.delete(id); const ruleIds = this.flagRulesIndex.get(rule.flagId); if (ruleIds) { ruleIds.delete(id); } } } async reorderRules(flagId, ruleIds) { for (let i = 0; i < ruleIds.length; i++) { const rule = this.rules.get(ruleIds[i]); if (rule && rule.flagId === flagId) { rule.priority = i; } } } // Override operations async createOverride(override) { const id = generateId(); const newOverride = { ...override, id, createdAt: /* @__PURE__ */ new Date() }; this.overrides.set(id, newOverride); const indexKey = `${override.flagId}:${override.userId}`; this.overrideIndex.set(indexKey, id); return newOverride; } async getOverride(flagId, userId) { const indexKey = `${flagId}:${userId}`; const id = this.overrideIndex.get(indexKey); return id ? this.overrides.get(id) || null : null; } async updateOverride(id, updates) { const override = this.overrides.get(id); if (!override) { throw new Error(`Override not found: ${id}`); } const updatedOverride = { ...override, ...updates, id: override.id, createdAt: override.createdAt }; this.overrides.set(id, updatedOverride); return updatedOverride; } async listOverrides(flagId, userId) { let overrides = Array.from(this.overrides.values()); if (flagId) { overrides = overrides.filter((o) => o.flagId === flagId); } if (userId) { overrides = overrides.filter((o) => o.userId === userId); } return overrides; } async deleteOverride(id) { const override = this.overrides.get(id); if (override) { const indexKey = `${override.flagId}:${override.userId}`; this.overrideIndex.delete(indexKey); this.overrides.delete(id); } } // Evaluation tracking async trackEvaluation(tracking) { const id = generateId(); const evaluation = { id, flagId: tracking.flagKey, // Note: this should be flagId in production userId: tracking.userId, value: tracking.value, variant: tracking.variant, reason: tracking.reason || "default", context: tracking.context || {}, evaluatedAt: tracking.timestamp }; this.evaluations.set(id, evaluation); } async getEvaluations(flagId, options) { let evaluations = Array.from(this.evaluations.values()).filter( (e) => e.flagId === flagId ); evaluations.sort( (a, b) => b.evaluatedAt.getTime() - a.evaluatedAt.getTime() ); const offset = options?.offset || 0; const limit = options?.limit || evaluations.length; return evaluations.slice(offset, offset + limit); } async getEvaluationStats(flagId, period) { let evaluations = Array.from(this.evaluations.values()).filter( (e) => e.flagId === flagId ); if (period) { evaluations = evaluations.filter( (e) => e.evaluatedAt >= period.start && e.evaluatedAt <= period.end ); } const uniqueUsers = new Set(evaluations.map((e) => e.userId)); const variants = {}; const reasons = {}; for (const evaluation of evaluations) { if (evaluation.variant) { variants[evaluation.variant] = (variants[evaluation.variant] || 0) + 1; } reasons[evaluation.reason] = (reasons[evaluation.reason] || 0) + 1; } return { totalEvaluations: evaluations.length, uniqueUsers: uniqueUsers.size, variants, reasons }; } // Audit logging async logAudit(entry) { const id = generateId(); const audit = { id, userId: entry.userId, action: entry.action, flagId: entry.flagKey || "", details: entry.metadata || {}, createdAt: entry.timestamp || /* @__PURE__ */ new Date() }; this.auditLogs.set(id, audit); } async getAuditLogs(options) { let logs = Array.from(this.auditLogs.values()); if (options?.userId) { logs = logs.filter((l) => l.userId === options.userId); } if (options?.flagId) { logs = logs.filter((l) => l.flagId === options.flagId); } if (options?.action) { logs = logs.filter((l) => l.action === options.action); } if (options?.startDate) { logs = logs.filter((l) => l.createdAt >= options.startDate); } if (options?.endDate) { logs = logs.filter((l) => l.createdAt <= options.endDate); } logs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); const offset = options?.offset || 0; const limit = options?.limit || logs.length; return logs.slice(offset, offset + limit); } async cleanupAuditLogs(olderThan) { let deletedCount = 0; for (const [id, log] of this.auditLogs.entries()) { if (log.createdAt < olderThan) { this.auditLogs.delete(id); deletedCount++; } } return deletedCount; } // Helper methods getFlagIndexKey(key, organizationId) { return organizationId ? `${organizationId}:${key}` : key; } }; // src/storage/database.ts var DatabaseStorage = class { constructor(config) { this.config = config; if (!config.db) { throw new Error("Database instance is required for DatabaseStorage"); } this.db = config.db; } static { __name(this, "DatabaseStorage"); } async initialize() { try { await this.db.query.featureFlags?.findFirst?.(); } catch (error) { console.warn( "[feature-flags] Database tables may not be initialized:", error ); } } async close() { } // Flag operations async createFlag(flag) { const id = generateId(); const now = /* @__PURE__ */ new Date(); const newFlag = await this.db.insert(this.db.schema.featureFlags).values({ id, ...flag, variants: flag.variants ? JSON.stringify(flag.variants) : null, createdAt: now, updatedAt: now }).returning(); return this.deserializeFlag(newFlag[0]); } async getFlag(key, organizationId) { const where = { key }; if (organizationId !== void 0) { where.organizationId = organizationId; } const flag = await this.db.query.featureFlags.findFirst({ where }); return flag ? this.deserializeFlag(flag) : null; } async getFlagById(id) { const flag = await this.db.query.featureFlags.findFirst({ where: { id } }); return flag ? this.deserializeFlag(flag) : null; } async listFlags(organizationId, options) { const where = {}; if (organizationId) { where.organizationId = organizationId; } if (options?.filter) { Object.assign(where, options.filter); } const query = { where, limit: options?.limit, offset: options?.offset }; if (options?.orderBy) { query.orderBy = { [options.orderBy]: options.orderDirection || "asc" }; } const flags = await this.db.query.featureFlags.findMany(query); return flags.map((f) => this.deserializeFlag(f)); } async updateFlag(id, updates) { const updateData = { ...updates, updatedAt: /* @__PURE__ */ new Date() }; if (updates.variants) { updateData.variants = JSON.stringify(updates.variants); } const updated = await this.db.update(this.db.schema.featureFlags).set(updateData).where({ id }).returning(); if (!updated[0]) { throw new Error(`Flag not found: ${id}`); } return this.deserializeFlag(updated[0]); } async deleteFlag(id) { await this.db.delete(this.db.schema.flagRules).where({ flagId: id }); await this.db.delete(this.db.schema.flagOverrides).where({ flagId: id }); await this.db.delete(this.db.schema.flagEvaluations).where({ flagId: id }); await this.db.delete(this.db.schema.flagAudits).where({ flagId: id }); await this.db.delete(this.db.schema.featureFlags).where({ id }); } // Rule operations async createRule(rule) { const id = generateId(); const newRule = await this.db.insert(this.db.schema.flagRules).values({ id, ...rule, conditions: JSON.stringify(rule.conditions), value: rule.value !== void 0 ? JSON.stringify(rule.value) : null, createdAt: /* @__PURE__ */ new Date() }).returning(); return this.deserializeRule(newRule[0]); } async getRulesForFlag(flagId) { const rules = await this.db.query.flagRules.findMany({ where: { flagId }, orderBy: { priority: "asc" } }); return rules.map((r) => this.deserializeRule(r)); } async updateRule(id, updates) { const updateData = { ...updates }; if (updates.conditions) { updateData.conditions = JSON.stringify(updates.conditions); } if (updates.value !== void 0) { updateData.value = JSON.stringify(updates.value); } const updated = await this.db.update(this.db.schema.flagRules).set(updateData).where({ id }).returning(); if (!updated[0]) { throw new Error(`Rule not found: ${id}`); } return this.deserializeRule(updated[0]); } async deleteRule(id) { await this.db.delete(this.db.schema.flagRules).where({ id }); } async reorderRules(flagId, ruleIds) { await this.db.transaction(async (tx) => { for (let i = 0; i < ruleIds.length; i++) { await tx.update(this.db.schema.flagRules).set({ priority: i }).where({ id: ruleIds[i], flagId }); } }); } // Override operations async createOverride(override) { const id = generateId(); const newOverride = await this.db.insert(this.db.schema.flagOverrides).values({ id, ...override, value: JSON.stringify(override.value), createdAt: /* @__PURE__ */ new Date() }).returning(); return this.deserializeOverride(newOverride[0]); } async getOverride(flagId, userId) { const override = await this.db.query.flagOverrides.findFirst({ where: { flagId, userId } }); return override ? this.deserializeOverride(override) : null; } async updateOverride(id, updates) { const updateData = { ...updates }; if (updates.value !== void 0) { updateData.value = JSON.stringify(updates.value); } const updated = await this.db.update(this.db.schema.flagOverrides).set(updateData).where({ id }).returning(); if (!updated[0]) { throw new Error(`Override not found: ${id}`); } return this.deserializeOverride(updated[0]); } async listOverrides(flagId, userId) { const where = {}; if (flagId) where.flagId = flagId; if (userId) where.userId = userId; const overrides = await this.db.query.flagOverrides.findMany({ where, orderBy: { createdAt: "desc" } }); return overrides.map((o) => this.deserializeOverride(o)); } async deleteOverride(id) { await this.db.delete(this.db.schema.flagOverrides).where({ id }); } // Evaluation tracking async trackEvaluation(tracking) { const id = generateId(); await this.db.insert(this.db.schema.flagEvaluations).values({ id, flagId: tracking.flagKey, // Note: should map to actual flagId userId: tracking.userId, value: tracking.value !== void 0 ? JSON.stringify(tracking.value) : null, variant: tracking.variant, reason: tracking.reason || "default", context: JSON.stringify(tracking.context || {}), evaluatedAt: tracking.timestamp }); } async getEvaluations(flagId, options) { const evaluations = await this.db.query.flagEvaluations.findMany({ where: { flagId }, orderBy: { evaluatedAt: "desc" }, limit: options?.limit, offset: options?.offset }); return evaluations.map((e) => this.deserializeEvaluation(e)); } async getEvaluationStats(flagId, period) { const where = { flagId }; if (period) { where.evaluatedAt = { gte: period.start, lte: period.end }; } const evaluations = await this.db.query.flagEvaluations.findMany({ where }); const uniqueUsers = new Set(evaluations.map((e) => e.userId)); const variants = {}; const reasons = {}; for (const evaluation of evaluations) { if (evaluation.variant) { variants[evaluation.variant] = (variants[evaluation.variant] || 0) + 1; } reasons[evaluation.reason] = (reasons[evaluation.reason] || 0) + 1; } return { totalEvaluations: evaluations.length, uniqueUsers: uniqueUsers.size, variants, reasons }; } // Audit logging async logAudit(entry) { const id = generateId(); await this.db.insert(this.db.schema.flagAudits).values({ id, userId: entry.userId, action: entry.action, flagId: entry.flagKey || null, details: JSON.stringify(entry.metadata || {}), createdAt: entry.timestamp || /* @__PURE__ */ new Date() }); } async getAuditLogs(options) { const where = {}; if (options?.userId) where.userId = options.userId; if (options?.flagId) where.flagId = options.flagId; if (options?.action) where.action = options.action; if (options?.startDate) { where.createdAt = { ...where.createdAt, gte: options.startDate }; } if (options?.endDate) { where.createdAt = { ...where.createdAt, lte: options.endDate }; } const logs = await this.db.query.flagAudits.findMany({ where, orderBy: { createdAt: "desc" }, limit: options?.limit, offset: options?.offset }); return logs.map((l) => this.deserializeAudit(l)); } async cleanupAuditLogs(olderThan) { const result = await this.db.delete(this.db.schema.flagAudits).where({ createdAt: { lt: olderThan } }); return result.rowCount || 0; } // Deserialization helpers deserializeFlag(flag) { return { ...flag, variants: flag.variants ? JSON.parse(flag.variants) : void 0, createdAt: new Date(flag.createdAt), updatedAt: new Date(flag.updatedAt) }; } deserializeRule(rule) { return { ...rule, conditions: JSON.parse(rule.conditions), value: rule.value ? JSON.parse(rule.value) : void 0, createdAt: new Date(rule.createdAt) }; } deserializeOverride(override) { return { ...override, value: JSON.parse(override.value), createdAt: new Date(override.createdAt) }; } deserializeEvaluation(evaluation) { return { ...evaluation, value: evaluation.value ? JSON.parse(evaluation.value) : void 0, context: JSON.parse(evaluation.context), evaluatedAt: new Date(evaluation.evaluatedAt) }; } deserializeAudit(audit) { return { ...audit, details: JSON.parse(audit.details), createdAt: new Date(audit.createdAt) }; } }; // src/storage/index.ts function createStorageAdapter(type, config) { switch (type) { case "memory": return new MemoryStorage(config); case "database": if (!config.db) { throw new Error("Database instance is required for database storage"); } return new DatabaseStorage(config); case "redis": throw new Error("Redis storage is not yet implemented"); default: throw new Error(`Unknown storage type: ${type}`); } } __name(createStorageAdapter, "createStorageAdapter"); // src/middleware/unified.ts function createUnifiedMiddleware(pluginContext, options = {}) { return createMiddleware(async (ctx) => { const mode = options.mode || "minimal"; const evaluationContext = await buildContextForMode( ctx, pluginContext, mode ); const evaluate = createEvaluator(pluginContext, evaluationContext); const evaluateBatch = createBatchEvaluator( pluginContext, evaluationContext ); const isEnabled = /* @__PURE__ */ __name(async (key) => { const result = await evaluate(key, false); return Boolean(result.value); }, "isEnabled"); const getVariant = /* @__PURE__ */ __name(async (key) => { const result = await evaluate(key); return result.variant; }, "getVariant"); const context = { featureFlags: { evaluate, evaluateBatch, isEnabled, getVariant } }; if (mode !== "minimal" || options.collectContext) { context.featureFlags.context = evaluationContext; } return context; }); } __name(createUnifiedMiddleware, "createUnifiedMiddleware"); async function buildContextForMode(ctx, pluginContext, mode) { const context = { userId: "anonymous", attributes: {} }; const getSession = ctx.getSession || ctx.auth?.getSession; const session = ctx.session || (getSession ? await getSession() : null); if (session?.user?.id) { context.userId = session.user.id; } switch (mode) { case "minimal": if (ctx.path && context.attributes) { context.attributes.requestPath = ctx.path; } if (ctx.method && context.attributes) { context.attributes.requestMethod = ctx.method; } break; case "session": if (session?.user && context.attributes) { if (session.user.email) context.attributes.email = session.user.email; if (session.user.name) context.attributes.name = session.user.name; if (session.user.roles) context.attributes.roles = session.user.roles; } if (ctx.path && context.attributes) { context.attributes.requestPath = ctx.path; } if (ctx.method && context.attributes) { context.attributes.requestMethod = ctx.method; } break; case "full": const { buildEvaluationContext: buildEvaluationContext2 } = await import("./context-FNMJMNER.js"); return await buildEvaluationContext2( ctx, session, pluginContext, pluginContext.config.contextCollection ); } if (pluginContext.config.multiTenant.enabled) { const organizationId = getOrganizationId(session, pluginContext); if (organizationId) { context.organizationId = organizationId; if (context.attributes) { context.attributes.organizationId = organizationId; } } } if (context.attributes) { context.attributes.timestamp = (/* @__PURE__ */ new Date()).toISOString(); } return context; } __name(buildContextForMode, "buildContextForMode"); function getOrganizationId(session, pluginContext) { if (!pluginContext.config.multiTenant.enabled) { return void 0; } if (pluginContext.config.multiTenant.useOrganizations) { return session?.organization?.id || session?.user?.organizationId; } return session?.user?.organizationId || session?.organizationId; } __name(getOrganizationId, "getOrganizationId"); function createEvaluator(pluginContext, evaluationContext) { return async (key, defaultValue) => { try { const { storage, config } = pluginContext; const organizationId = config.multiTenant.enabled ? evaluationContext.organizationId : void 0; const cacheKey = `flag:${key}:${JSON.stringify(evaluationContext)}`; if (config.caching.enabled && pluginContext.cache.has(cacheKey)) { const cached = pluginContext.cache.get(cacheKey); if (cached && cached.timestamp + config.caching.ttl * 1e3 > Date.now()) { return cached.value; } } const flag = await storage.getFlag(key, organizationId); if (!flag || !flag.enabled) { return { value: defaultValue, reason: flag ? "disabled" : "not_found" }; } const { evaluateFlags: evaluateFlags2 } = await import("./evaluation-VREABOZR.js"); const result = await evaluateFlags2( flag, evaluationContext, pluginContext ); if (config.caching.enabled) { pluginContext.cache.set(cacheKey, { value: result, timestamp: Date.now(), ttl: config.caching.ttl, reason: result.reason }); } if (config.analytics.trackUsage) { storage.trackEvaluation({ flagKey: key, userId: evaluationContext.userId || "anonymous", context: evaluationContext, timestamp: /* @__PURE__ */ new Date(), value: result.value, variant: result.variant, reason: result.reason }).catch((err) => { console.error( `[feature-flags] Failed to track evaluation: ${err.message}` ); }); } return result; } catch (error) { console.error(`[feature-flags] Error evaluating flag ${key}:`, error); return { value: defaultValue, reason: "error" }; } }; } __name(createEvaluator, "createEvaluator"); function createBatchEvaluator(pluginContext, evaluationContext) { return async (keys) => { const evaluate = createEvaluator(pluginContext, evaluationContext); const results = {}; const evaluations = await Promise.all( keys.map(async (key) => { const result = await evaluate(key); return { key, result }; }) ); for (const { key, result } of evaluations) { results[key] = result; } return results; }; } __name(createBatchEvaluator, "createBatchEvaluator"); // src/middleware/admin.ts function createAdminProtectionMiddleware(pluginContext) { return createMiddleware(async (ctx) => { const { config } = pluginContext; if (!config.adminAccess.enabled) { throw new Error("Admin access is disabled"); } const getSession = ctx.getSession || ctx.auth?.getSession; const session = ctx.session || (getSession ? await getSession() : null); if (!session?.user) { throw new Error("Unauthorized: No active session"); } const userRoles = session.user.roles || []; const hasAdminRole = config.adminAccess.roles.some( (role) => userRoles.includes(role) ); if (!hasAdminRole) { throw new Error("Insufficient permissions: Admin role required"); } return { admin: { userId: session.user.id, roles: userRoles, isAdmin: true } }; }); } __name(createAdminProtectionMiddleware, "createAdminProtectionMiddleware"); function createAuditMiddleware(pluginContext) { return createMiddleware(async (ctx) => { const { config, storage } = pluginContext; if (!config.audit.enabled) { return {}; } const getSession = ctx.getSession || ctx.auth?.getSession; const session = ctx.session || (getSession ? await getSession() : null); const action = ctx.method === "POST" ? "create" : ctx.method === "PATCH" ? "update" : ctx.method === "DELETE" ? "delete" : "read"; const flagKey = ctx.params?.key || ctx.body?.key; const ip = ctx.headers?.get?.("x-forwarded-for") || ctx.headers?.get?.("x-real-ip") || ctx.headers?.get?.("cf-connecting-ip") || ctx.request?.ip || "unknown"; await storage.logAudit({ userId: session?.user?.id || "anonymous", action, flagKey, metadata: { path: ctx.path, method: ctx.method, ip: ip.split(",")[0].trim(), userAgent: ctx.headers?.get?.("user-agent"), timestamp: (/* @__PURE__ */ new Date()).toISOString() } }).catch((error) => { console.error("[feature-flags] Failed to log audit:", error); }); return {}; }); } __name(createAuditMiddleware, "createAuditMiddleware"); function createCacheInvalidationMiddleware(pluginContext) { return createMiddleware(async (ctx) => { const { cache, config } = pluginContext; if (!config.caching.enabled || !cache) { return {}; } if (!["PATCH", "DELETE", "POST"].includes(ctx.method)) { return {}; } const flagKey = ctx.params?.key || ctx.body?.key; if (flagKey) { let clearedCount = 0; for (const key of cache.keys()) { if (key.includes(flagKey)) { cache.delete(key); clearedCount++; } } if (clearedCount > 0) { console.log( `[feature-flags] Cleared ${clearedCount} cache entries for flag: ${flagKey}` ); } } return {}; }); } __name(createCacheInvalidationMiddleware, "createCacheInvalidationMiddleware"); // src/hooks/index.ts import { createAuthMiddleware } from "better-auth/plugins"; function createLifecycleHooks(pluginContext, type) { if (type === "before") { return createBeforeHooks(pluginContext); } else { return createAfterHooks(pluginContext); } } __name(createLifecycleHooks, "createLifecycleHooks"); function createBeforeHooks(pluginContext) { const hooks = []; hooks.push({ matcher: /* @__PURE__ */ __name((ctx) => ctx.path.startsWith("/api/flags/evaluate"), "matcher"), handler: createAuthMiddleware(async (ctx) => { if (ctx.method === "POST" && !ctx.body) { throw new Error("Request body is required"); } return; }) }); if (pluginContext.config.adminAccess.enabled) { hooks.push({ matcher: /* @__PURE__ */ __name((ctx) => ctx.path.startsWith("/api/admin/flags"), "matcher"), handler: createAuthMiddleware(async (ctx) => { const session = await ctx.getSession?.(); if (session?.user?.permissions) { const hasPermission = session.user.permissions.includes( "feature_flags:admin" ); if (!hasPermission && !session.user.permissions.includes("admin:all")) { throw new Error("Missing feature flags admin permission"); } } if (pluginContext.config.audit.enabled) { await pluginContext.storage.logAudit({ userId: session?.user?.id || "anonymous", action: "admin_access", metadata: { path: ctx.path, method: ctx.method, timestamp: (/* @__PURE__ */ new Date()).toISOString() } }).catch((err) => { console.error("[feature-flags] Failed to log admin access:", err); }); } return; }) }); } hooks.push({ matcher: /* @__PURE__ */ __name((ctx) => ctx.path.startsWith("/api/admin/flags") && (ctx.method === "POST" || ctx.method === "PATCH"), "matcher"), handler: createAuthMiddleware(async (ctx) => { const body = ctx.body; if (!body) { throw new Error("Request body is required"); } if (ctx.method === "POST") { if (!body.key) { throw new Error("Flag key is required"); } if (!body.type) { throw new Error("Flag type is required"); } if (!["boolean", "string", "number", "json"].includes(body.type)) { throw new Error(`Invalid flag type: ${body.type}`); } } if (body.variants) { if (typeof body.variants !== "object") { throw new Error("Variants must be an object"); } if (Object.keys(body.variants).length === 0) { throw new Error("Variants object cannot be empty"); } } if (body.rolloutPercentage !== void 0) { const percentage = Number(body.rolloutPercentage); if (isNaN(percentage) || percentage < 0 || percentage > 100) { throw new Error("Rollout percentage must be between 0 and 100"); } } return; }) }); return hooks; } __name(createBeforeHooks, "createBeforeHooks"); function createAfterHooks(pluginContext) { const hooks = []; if (pluginContext.config.analytics.trackPerformance) { hooks.push({ matcher: /* @__PURE__ */ __name((ctx) => ctx.path.startsWith("/api/flags/evaluate"), "matcher"), handler: createAuthMiddleware(async (ctx) => { const startTime = ctx.startTime || Date.now(); const duration = Date.now() - startTime; if (duration > 100) { console.warn( `[feature-flags] Slow evaluation: ${duration}ms for ${ctx.path}` ); } if (ctx.response?.ok) { const metrics = { path: ctx.path, duration, timestamp: /* @__PURE__ */ new Date(), userId: ctx.session?.user?.id || "anonymous" }; if (process.env.NODE_ENV === "development") { console.debug("[feature-flags] Performance:", metrics); } } return; }) }); } hooks.push({ matcher: /* @__PURE__ */ __name((ctx) => ctx.path === "/api/admin/flags" && ctx.method === "GET", "matcher"), handler: createAuthMiddleware(async (ctx) => { if (pluginContext.config.caching.enabled && ctx.response?.ok) { const flags = ctx.response.body?.flags || []; const topFlags = flags.filter((f) => f.evaluationCount > 1e3).slice(0, 10); for (const flag of topFlags) { const cacheKey = `flag:${flag.key}`; pluginContext.cache.set(cacheKey, { value: flag, variant: void 0, reason: "cached", timestamp: Date.now(), ttl: pluginContext.config.caching.ttl }); } } return; }) }); hooks.push({ matcher: /* @__PURE__ */ __name((ctx) => ctx.path === "/api/admin/flags/cleanup", "matcher"), handler: createAuthMiddleware(async (ctx) => { if (!pluginContext.config.audit.enabled) { return; } const retentionDate = /* @__PURE__ */ new Date(); retentionDate.setDate( retentionDate.getDate() - pluginContext.config.audit.retentionDays ); try { const deletedCount = await pluginContext.storage.cleanupAuditLogs(retentionDate); console.log( `[feature-flags] Cleaned up ${deletedCount} old audit logs` ); } catch (error) { console.error("[feature-flags] Failed to cleanup audit logs:", error); } return; }) }); hooks.push({ matcher: /* @__PURE__ */ __name((ctx) => ctx.path.startsWith("/api/admin/flags") && (ctx.method === "PATCH" || ctx.method === "DELETE"), "matcher"), handler: createAuthMiddleware(async (ctx) => { if (!ctx.response?.ok) { return; } const flagKey = ctx.params?.key || ctx.body?.key; if (flagKey) { const updateEvent = { type: ctx.method === "DELETE" ? "flag_deleted" : "flag_updated", flagKey, timestamp: /* @__PURE__ */ new Date(), userId: ctx.session?.user?.id }; if (process.env.NODE_ENV === "development") { console.debug("[feature-flags] Flag update event:", updateEvent); } } return; }) }); return hooks; } __name(createAfterHooks, "createAfterHooks"); // src/endpoints/index.ts function createFlagEndpoints(pluginContext) { return { // User-facing endpoints for flag evaluation "/api/flags/evaluate/:key": { GET: /* @__PURE__ */ __name(async (ctx) => { const { key } = ctx.params; const session = await ctx.getSession(); const baseContext = ctx.featureFlags?.context || await buildEvaluationContext(ctx, session, pluginContext); const additionalContext = ctx.query.context ? JSON.parse(ctx.query.context) : {}; const evaluationContext = { ...baseContext, attributes: { ...baseContext.attributes, ...additionalContext.attributes }, ...additionalContext }; const organizationId = pluginContext.config.multiTenant.enabled ? evaluationContext.organizationId : void 0; const flag = await pluginContext.storage.getFlag(key, organizationId); if (!flag) { return { value: ctx.query.default || void 0, reason: "not_found" }; } const result = await evaluateFlags( flag, evaluationContext, pluginContext ); if (pluginContext.config.analytics.trackUsage) { await pluginContext.storage.trackEvaluation({ flagKey: key, userId: evaluationContext.userId, context: evaluationContext, timestamp: /* @__PURE__ */ new Date(), value: result.value,