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
JavaScript
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,