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,494 lines (1,485 loc) • 185 kB
JavaScript
import {
evaluateFlags,
evaluateFlagsBatch,
generateId,
parseJSON
} from "./chunk-OUXFW62S.js";
import {
DEFAULT_HEADER_CONFIG,
buildEvaluationContext
} from "./chunk-DAJILJMT.js";
import {
__name
} from "./chunk-SHUYVCID.js";
// src/endpoints/admin/analytics.ts
import { createAuthEndpoint, sessionMiddleware } from "better-auth/api";
import { z } from "zod";
// src/endpoints/shared.ts
async function buildCtx(ctx, pluginContext, additionalContext) {
const session = ctx.context?.session ?? null;
const baseContext = await buildEvaluationContext(ctx, session, pluginContext);
const extra = additionalContext || {};
return {
...baseContext,
attributes: {
...baseContext.attributes,
...extra.attributes
},
...extra
};
}
__name(buildCtx, "buildCtx");
function jsonError(ctx, code, message, status = 400, details) {
return ctx.json(
{
error: code,
message,
...details ? { details } : {}
},
{ status }
);
}
__name(jsonError, "jsonError");
function resolveEffectiveOrgId(ctx, pluginContext, organizationId) {
if (!pluginContext.config.multiTenant.enabled) {
return { ok: true, organizationId: void 0 };
}
const session = ctx.context?.session;
const userOrgId = session?.user?.organizationId;
if (!userOrgId) {
return {
ok: false,
response: jsonError(
ctx,
"UNAUTHORIZED_ACCESS",
"Organization ID required for multi-tenant access",
403
)
};
}
if (organizationId && organizationId !== userOrgId) {
return {
ok: false,
response: jsonError(
ctx,
"UNAUTHORIZED_ACCESS",
"Access denied to requested organization",
403
)
};
}
return { ok: true, organizationId: userOrgId };
}
__name(resolveEffectiveOrgId, "resolveEffectiveOrgId");
function parseDateRange(input) {
if (!input.startDate || !input.endDate) return void 0;
return {
start: new Date(input.startDate),
end: new Date(input.endDate)
};
}
__name(parseDateRange, "parseDateRange");
function validateAnalyticsDateRange(input, options = {}) {
const { maxDays = 90 } = options;
if (!input.startDate || !input.endDate) {
return { ok: true, dateRange: void 0 };
}
let start, end;
try {
start = new Date(input.startDate);
end = new Date(input.endDate);
} catch {
return {
ok: false,
error: "Invalid date format. Use ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss.sssZ)",
code: "INVALID_DATE_FORMAT"
};
}
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return {
ok: false,
error: "Invalid date values. Use valid ISO 8601 dates",
code: "INVALID_DATE_VALUES"
};
}
if (start > end) {
return {
ok: false,
error: "Start date must be less than or equal to end date",
code: "INVALID_DATE_RANGE"
};
}
const diffMs = end.getTime() - start.getTime();
const diffDays = diffMs / (1e3 * 60 * 60 * 24);
if (diffDays > maxDays) {
return {
ok: false,
error: `Date range too large. Maximum allowed range is ${maxDays} days`,
code: "DATE_RANGE_TOO_LARGE"
};
}
return {
ok: true,
dateRange: { start, end }
};
}
__name(validateAnalyticsDateRange, "validateAnalyticsDateRange");
async function ensureFlagOwnership(ctx, pluginContext, flagId) {
if (!pluginContext.config.multiTenant.enabled) return { ok: true };
const session = ctx.context?.session;
const userOrgId = session?.user?.organizationId;
if (!userOrgId) {
return {
ok: false,
response: jsonError(
ctx,
"UNAUTHORIZED_ACCESS",
"Organization ID required for multi-tenant access",
403
)
};
}
const flag = await pluginContext.storage.getFlagById(flagId);
if (!flag || flag.organizationId !== userOrgId) {
return {
ok: false,
response: jsonError(
ctx,
"FLAG_NOT_FOUND",
"Flag not found in your organization",
404
)
};
}
return { ok: true };
}
__name(ensureFlagOwnership, "ensureFlagOwnership");
function resolveEnvironment(ctx, bodyEnvironment) {
const headerEnvironment = ctx.headers?.["x-deployment-ring"];
if (typeof headerEnvironment === "string" && headerEnvironment.trim()) {
return headerEnvironment.trim();
}
return bodyEnvironment;
}
__name(resolveEnvironment, "resolveEnvironment");
// src/endpoints/admin/analytics.ts
function createAdminAnalyticsEndpoints(pluginContext) {
const getFeatureFlagStatsHandler = createAuthEndpoint(
"/feature-flags/admin/flags/:flagId/stats",
{
method: "GET",
use: [sessionMiddleware],
query: z.object({
granularity: z.enum(["hour", "day", "week", "month"]).optional(),
start: z.string().optional(),
end: z.string().optional(),
timezone: z.string().optional(),
metrics: z.array(
z.enum([
"total",
"uniqueUsers",
"errorRate",
"avgLatency",
"variants",
"reasons"
])
).optional()
}).optional(),
metadata: {
openapi: {
operationId: "auth.api.getFeatureFlagStats",
summary: "Get Flag Statistics",
description: "Get usage statistics for a feature flag with optional date range validation (max 90 days) and selective metrics projection (admin only)"
}
}
},
async (ctx) => {
try {
const flagId = ctx.params?.flagId;
const { granularity, start, end, timezone, metrics } = ctx.query || {};
if (pluginContext.config.multiTenant.enabled) {
const res = await ensureFlagOwnership(ctx, pluginContext, flagId);
if (!res.ok) return res.response;
}
const dateValidation = validateAnalyticsDateRange(
{ startDate: start, endDate: end },
{ maxDays: 90 }
);
if (!dateValidation.ok) {
return jsonError(ctx, dateValidation.code, dateValidation.error, 400);
}
const dateRange = dateValidation.dateRange;
const stats = await pluginContext.storage.getEvaluationStats(
flagId,
dateRange,
{ granularity, timezone, metrics }
);
return ctx.json({ stats });
} catch (error) {
console.error("[feature-flags] Error getting stats:", error);
return jsonError(
ctx,
"STORAGE_ERROR",
"Failed to retrieve statistics",
500
);
}
}
);
const getFeatureFlagsUsageMetricsHandler = createAuthEndpoint(
"/feature-flags/admin/metrics/usage",
{
method: "GET",
use: [sessionMiddleware],
query: z.object({
start: z.string().optional(),
end: z.string().optional(),
timezone: z.string().optional(),
organizationId: z.string().optional(),
metrics: z.array(
z.enum([
"total",
"uniqueUsers",
"errorRate",
"avgLatency",
"variants",
"reasons"
])
).optional()
}).optional(),
metadata: {
openapi: {
operationId: "auth.api.getFeatureFlagsUsageMetrics",
summary: "Get Usage Metrics",
description: "Get operational usage metrics across all flags with optional date range validation (max 90 days) and selective metrics projection (admin only)"
}
}
},
async (ctx) => {
try {
const { start, end, organizationId, metrics } = ctx.query || {};
const orgResult = resolveEffectiveOrgId(
ctx,
pluginContext,
organizationId
);
if (!orgResult.ok) return orgResult.response;
const effectiveOrgId = orgResult.organizationId;
const dateValidation = validateAnalyticsDateRange(
{ startDate: start, endDate: end },
{ maxDays: 90 }
);
if (!dateValidation.ok) {
return jsonError(ctx, dateValidation.code, dateValidation.error, 400);
}
const period = dateValidation.dateRange;
const usageMetrics = await pluginContext.storage.getUsageMetrics(
effectiveOrgId,
period,
{ metrics }
);
return ctx.json({
metrics: {
...usageMetrics,
organizationId: effectiveOrgId,
timeRange: { start, end }
}
});
} catch (error) {
console.error("[feature-flags] Error getting usage metrics:", error);
return jsonError(
ctx,
"STORAGE_ERROR",
"Failed to retrieve usage metrics",
500
);
}
}
);
return {
// Analytics
getFeatureFlagStats: getFeatureFlagStatsHandler,
getFeatureFlagsUsageMetrics: getFeatureFlagsUsageMetricsHandler
};
}
__name(createAdminAnalyticsEndpoints, "createAdminAnalyticsEndpoints");
// src/endpoints/admin/audit.ts
import { createAuthEndpoint as createAuthEndpoint2, sessionMiddleware as sessionMiddleware2 } from "better-auth/api";
import { z as z2 } from "zod";
function createAdminAuditEndpoints(pluginContext) {
const listFeatureFlagAuditEntriesHandler = createAuthEndpoint2(
"/feature-flags/admin/audit",
{
method: "GET",
use: [sessionMiddleware2],
query: z2.object({
flagId: z2.string().optional(),
userId: z2.string().optional(),
action: z2.enum(["create", "update", "delete", "evaluate"]).optional(),
start: z2.string().optional(),
end: z2.string().optional(),
cursor: z2.string().optional(),
limit: z2.coerce.number().min(1).max(100).optional(),
q: z2.string().optional(),
sort: z2.string().optional()
// e.g., "-timestamp", "action"
}).optional(),
metadata: {
openapi: {
operationId: "auth.api.listFeatureFlagAuditEntries",
summary: "List Audit Entries",
description: "Retrieve audit log entries (admin only)"
}
}
},
async (ctx) => {
try {
const { flagId, userId, action, start, end, cursor, limit, q, sort } = ctx.query || {};
if (pluginContext.config.multiTenant.enabled && flagId) {
const res = await ensureFlagOwnership(ctx, pluginContext, flagId);
if (!res.ok) return res.response;
}
const pageSize = limit ?? 50;
const decodeCursor = /* @__PURE__ */ __name((c) => {
if (!c) return 0;
try {
const s = Buffer.from(c, "base64").toString("utf8");
const n = Number(s);
return Number.isFinite(n) && n >= 0 ? n : 0;
} catch {
return 0;
}
}, "decodeCursor");
const encodeCursor = /* @__PURE__ */ __name((n) => Buffer.from(String(n), "utf8").toString("base64"), "encodeCursor");
const offset = decodeCursor(cursor);
const parseSort = /* @__PURE__ */ __name((s) => {
if (!s) return {};
const desc = s.startsWith("-");
const field = desc ? s.slice(1) : s;
if (!field) return {};
return {
orderBy: field,
orderDirection: desc ? "desc" : "asc"
};
}, "parseSort");
const sortSpec = parseSort(sort);
const range = parseDateRange({ startDate: start, endDate: end });
const filters = {
flagId,
userId,
action,
startDate: range?.start,
endDate: range?.end
};
const allEntries = await pluginContext.storage.getAuditLogs(filters);
let filtered = allEntries;
if (q && q.trim().length > 0) {
const needle = q.toLowerCase();
filtered = filtered.filter(
(entry) => (entry.flagId || "").toLowerCase().includes(needle) || (entry.userId || "").toLowerCase().includes(needle) || (entry.action || "").toLowerCase().includes(needle)
);
}
const sorted = sortSpec.orderBy ? [...filtered].sort((a, b) => {
const dir = (sortSpec.orderDirection || "asc") === "desc" ? -1 : 1;
const av = a[sortSpec.orderBy];
const bv = b[sortSpec.orderBy];
if (av === bv) return 0;
return av > bv ? dir : -dir;
}) : filtered;
const page = sorted.slice(offset, offset + pageSize);
const hasMore = offset + pageSize < sorted.length;
const nextCursor = hasMore ? encodeCursor(offset + pageSize) : void 0;
return ctx.json({
entries: page,
page: {
nextCursor,
limit: pageSize,
hasMore
}
});
} catch (error) {
console.error("[feature-flags] Error getting audit log:", error);
return jsonError(
ctx,
"STORAGE_ERROR",
"Failed to retrieve audit log",
500
);
}
}
);
const getFeatureFlagAuditEntryHandler = createAuthEndpoint2(
"/feature-flags/admin/audit/:id",
{
method: "GET",
use: [sessionMiddleware2],
metadata: {
openapi: {
operationId: "auth.api.getFeatureFlagAuditEntry",
summary: "Get Audit Entry",
description: "Get a specific audit log entry (admin only)"
}
}
},
async (ctx) => {
try {
const id = ctx.params?.id;
if (!id) {
return jsonError(
ctx,
"INVALID_AUDIT_ID",
"Audit entry ID is required",
400
);
}
const entry = await pluginContext.storage.getAuditEntry(id);
if (!entry) {
return jsonError(
ctx,
"AUDIT_NOT_FOUND",
"Audit entry not found",
404
);
}
return ctx.json({ entry });
} catch (error) {
console.error("[feature-flags] Error getting audit entry:", error);
return jsonError(
ctx,
"STORAGE_ERROR",
"Failed to retrieve audit entry",
500
);
}
}
);
return {
// Audit
listFeatureFlagAuditEntries: listFeatureFlagAuditEntriesHandler,
getFeatureFlagAuditEntry: getFeatureFlagAuditEntryHandler
};
}
__name(createAdminAuditEndpoints, "createAdminAuditEndpoints");
// src/endpoints/admin/environments.ts
import { createAuthEndpoint as createAuthEndpoint3, sessionMiddleware as sessionMiddleware3 } from "better-auth/api";
import { z as z3 } from "zod";
function createAdminEnvironmentsEndpoints(pluginContext) {
const listFeatureFlagEnvironmentsHandler = createAuthEndpoint3(
"/feature-flags/admin/environments",
{
method: "GET",
use: [sessionMiddleware3],
query: z3.object({
organizationId: z3.string().optional(),
cursor: z3.string().optional(),
limit: z3.coerce.number().min(1).max(100).optional(),
q: z3.string().optional(),
sort: z3.string().optional()
// e.g., "-name", "key"
}).optional(),
metadata: {
openapi: {
operationId: "auth.api.listFeatureFlagEnvironments",
summary: "List Environments",
description: "Get all environments (admin only)"
}
}
},
async (ctx) => {
try {
const { organizationId, cursor, limit, q, sort } = ctx.query || {};
const orgResult = resolveEffectiveOrgId(
ctx,
pluginContext,
organizationId
);
if (!orgResult.ok) return orgResult.response;
const effectiveOrgId = orgResult.organizationId;
const pageSize = limit ?? 50;
const decodeCursor = /* @__PURE__ */ __name((c) => {
if (!c) return 0;
try {
const s = Buffer.from(c, "base64").toString("utf8");
const n = Number(s);
return Number.isFinite(n) && n >= 0 ? n : 0;
} catch {
return 0;
}
}, "decodeCursor");
const encodeCursor = /* @__PURE__ */ __name((n) => Buffer.from(String(n), "utf8").toString("base64"), "encodeCursor");
const offset = decodeCursor(cursor);
const parseSort = /* @__PURE__ */ __name((s) => {
if (!s) return {};
const desc = s.startsWith("-");
const field = desc ? s.slice(1) : s;
if (!field) return {};
return {
orderBy: field,
orderDirection: desc ? "desc" : "asc"
};
}, "parseSort");
const sortSpec = parseSort(sort);
const allEnvironments = [
{
id: "default",
name: "Default",
key: "default",
description: "Default environment",
organizationId: effectiveOrgId
},
{
id: "development",
name: "Development",
key: "development",
description: "Development environment",
organizationId: effectiveOrgId
},
{
id: "staging",
name: "Staging",
key: "staging",
description: "Staging environment",
organizationId: effectiveOrgId
},
{
id: "production",
name: "Production",
key: "production",
description: "Production environment",
organizationId: effectiveOrgId
}
];
let filtered = allEnvironments;
if (q && q.trim().length > 0) {
const needle = q.toLowerCase();
filtered = filtered.filter(
(env) => (env.name || "").toLowerCase().includes(needle) || (env.key || "").toLowerCase().includes(needle) || (env.description || "").toLowerCase().includes(needle)
);
}
const sorted = sortSpec.orderBy ? [...filtered].sort((a, b) => {
const dir = (sortSpec.orderDirection || "asc") === "desc" ? -1 : 1;
const av = a[sortSpec.orderBy];
const bv = b[sortSpec.orderBy];
if (av === bv) return 0;
return av > bv ? dir : -dir;
}) : filtered;
const page = sorted.slice(offset, offset + pageSize);
const hasMore = offset + pageSize < sorted.length;
const nextCursor = hasMore ? encodeCursor(offset + pageSize) : void 0;
return ctx.json({
environments: page,
page: {
nextCursor,
limit: pageSize,
hasMore
}
});
} catch (error) {
console.error("[feature-flags] Error listing environments:", error);
return jsonError(
ctx,
"STORAGE_ERROR",
"Failed to list environments",
500
);
}
}
);
const createFeatureFlagEnvironmentHandler = createAuthEndpoint3(
"/feature-flags/admin/environments",
{
method: "POST",
use: [sessionMiddleware3],
body: z3.object({
name: z3.string(),
key: z3.string().refine((val) => /^[a-z0-9-_]+$/i.test(val), {
message: "Key must contain only alphanumeric characters, hyphens, and underscores"
}).optional().describe("Stable slug identifier for environment"),
description: z3.string().optional(),
organizationId: z3.string().optional(),
config: z3.record(z3.string(), z3.any()).optional()
}),
metadata: {
openapi: {
operationId: "auth.api.createFeatureFlagEnvironment",
summary: "Create Environment",
description: "Create a new environment (admin only)"
}
}
},
async (ctx) => {
try {
const envData = { ...ctx.body };
if (pluginContext.config.multiTenant.enabled && !envData.organizationId) {
const session = ctx.context.session;
if (session?.user?.organizationId) {
envData.organizationId = session.user.organizationId;
} else {
return jsonError(
ctx,
"ORGANIZATION_REQUIRED",
"Organization ID is required when multi-tenant is enabled",
400
);
}
}
const key = envData.key || envData.name.toLowerCase().replace(/[^a-z0-9-_]/gi, "-").replace(/-+/g, "-");
const environment = {
id: "env-" + Date.now(),
...envData,
key,
createdAt: (/* @__PURE__ */ new Date()).toISOString()
};
return ctx.json(environment);
} catch (error) {
console.error("[feature-flags] Error creating environment:", error);
return jsonError(
ctx,
"STORAGE_ERROR",
"Failed to create environment",
500
);
}
}
);
const updateFeatureFlagEnvironmentHandler = createAuthEndpoint3(
"/feature-flags/admin/environments/:id",
{
method: "PATCH",
use: [sessionMiddleware3],
body: z3.object({
name: z3.string().optional(),
key: z3.string().refine((val) => /^[a-z0-9-_]+$/i.test(val), {
message: "Key must contain only alphanumeric characters, hyphens, and underscores"
}).optional().describe("Stable slug identifier for environment"),
description: z3.string().optional(),
config: z3.record(z3.string(), z3.any()).optional()
}),
metadata: {
openapi: {
operationId: "auth.api.updateFeatureFlagEnvironment",
summary: "Update Environment",
description: "Update an environment (admin only)"
}
}
},
async (ctx) => {
try {
const id = ctx.params?.id;
const updates = ctx.body;
if (!id) {
return jsonError(
ctx,
"INVALID_ENVIRONMENT_ID",
"Environment ID is required",
400
);
}
const updatedEnvironment = {
id,
name: updates.name || "Updated Environment",
description: updates.description || "Updated environment description",
config: updates.config || {},
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
};
return ctx.json(updatedEnvironment);
} catch (error) {
console.error("[feature-flags] Error updating environment:", error);
return jsonError(
ctx,
"STORAGE_ERROR",
"Failed to update environment",
500
);
}
}
);
const deleteFeatureFlagEnvironmentHandler = createAuthEndpoint3(
"/feature-flags/admin/environments/:id",
{
method: "DELETE",
use: [sessionMiddleware3],
metadata: {
openapi: {
operationId: "auth.api.deleteFeatureFlagEnvironment",
summary: "Delete Environment",
description: "Delete an environment (admin only)"
}
}
},
async (ctx) => {
try {
const id = ctx.params?.id;
if (!id) {
return jsonError(
ctx,
"INVALID_ENVIRONMENT_ID",
"Environment ID is required",
400
);
}
return new Response(null, { status: 204 });
} catch (error) {
console.error("[feature-flags] Error deleting environment:", error);
return jsonError(
ctx,
"STORAGE_ERROR",
"Failed to delete environment",
500
);
}
}
);
const exportFeatureFlagDataHandler = createAuthEndpoint3(
"/feature-flags/admin/export",
{
method: "POST",
use: [sessionMiddleware3],
body: z3.object({
format: z3.enum(["json", "csv", "yaml"]).default("json"),
includeFlags: z3.boolean().default(true),
includeRules: z3.boolean().default(true),
includeOverrides: z3.boolean().default(false),
includeAuditLog: z3.boolean().default(false),
organizationId: z3.string().optional(),
flagIds: z3.array(z3.string()).optional()
}),
metadata: {
openapi: {
operationId: "auth.api.exportFeatureFlagData",
summary: "Export Data",
description: "Export feature flag data in various formats (admin only)"
}
}
},
async (ctx) => {
try {
const {
format,
includeFlags,
includeRules,
includeOverrides,
includeAuditLog,
organizationId,
flagIds
} = ctx.body;
const orgResult = resolveEffectiveOrgId(
ctx,
pluginContext,
organizationId
);
if (!orgResult.ok) return orgResult.response;
const effectiveOrgId = orgResult.organizationId;
const exportData = {
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
format,
organizationId: effectiveOrgId
};
if (includeFlags) {
const flags = await pluginContext.storage.listFlags(effectiveOrgId);
exportData.flags = flags;
}
if (includeRules && includeFlags) {
const rules = await Promise.all(
(exportData.flags || []).map(async (flag) => ({
flagId: flag.id,
rules: await pluginContext.storage.getRulesForFlag(flag.id)
}))
);
exportData.rules = rules;
}
if (includeOverrides) {
const overrides = await pluginContext.storage.listOverrides(
flagIds?.[0],
// If specific flag IDs provided, get overrides for first one
void 0
);
exportData.overrides = overrides;
}
if (includeAuditLog) {
const auditEntries = await pluginContext.storage.getAuditLogs({
flagId: flagIds?.[0],
limit: 1e3
});
exportData.auditLog = auditEntries;
}
const contentType = {
json: "application/json",
csv: "text/csv",
yaml: "application/yaml"
}[format];
return ctx.json(exportData, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `attachment; filename="feature-flags-export.${format}"`
}
});
} catch (error) {
console.error("[feature-flags] Error exporting data:", error);
return jsonError(ctx, "EXPORT_ERROR", "Failed to export data", 500);
}
}
);
return {
// Environments
listFeatureFlagEnvironments: listFeatureFlagEnvironmentsHandler,
createFeatureFlagEnvironment: createFeatureFlagEnvironmentHandler,
updateFeatureFlagEnvironment: updateFeatureFlagEnvironmentHandler,
deleteFeatureFlagEnvironment: deleteFeatureFlagEnvironmentHandler,
// Data Export (grouped with environments as it's operational/admin functionality)
exportFeatureFlagData: exportFeatureFlagDataHandler
};
}
__name(createAdminEnvironmentsEndpoints, "createAdminEnvironmentsEndpoints");
// src/endpoints/admin/flags.ts
import { createAuthEndpoint as createAuthEndpoint4, sessionMiddleware as sessionMiddleware4 } from "better-auth/api";
import { z as z5 } from "zod";
// src/schema/validation.ts
import * as z4 from "zod";
var flagTypeSchema = z4.enum(["boolean", "string", "number", "json"]);
var evaluationReasonSchema = z4.enum([
"rule_match",
"override",
"percentage_rollout",
"default",
"disabled",
"not_found"
]);
var auditActionSchema = z4.enum([
"created",
"updated",
"deleted",
"enabled",
"disabled",
"rule_added",
"rule_updated",
"rule_deleted",
"override_added",
"override_removed"
]);
var conditionOperatorSchema = z4.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 = z4.object({
attribute: z4.string(),
operator: conditionOperatorSchema,
value: z4.any()
});
var ruleConditionsSchema = z4.object({
all: z4.array(conditionSchema).optional(),
any: z4.array(conditionSchema).optional(),
not: z4.lazy(() => ruleConditionsSchema).optional()
});
var flagRuleInputSchema = z4.object({
name: z4.string().optional(),
priority: z4.number().default(0),
conditions: ruleConditionsSchema,
value: z4.any(),
percentage: z4.number().min(0).max(100).optional(),
enabled: z4.boolean().default(true)
});
var variantSchema = z4.object({
key: z4.string(),
value: z4.any(),
weight: z4.number().min(0).max(100),
metadata: z4.record(z4.string(), z4.any()).optional()
});
var featureFlagInputSchema = z4.object({
key: z4.string().refine((val) => /^[a-z0-9-_]+$/i.test(val), {
message: "Key must contain only alphanumeric characters, hyphens, and underscores"
}),
name: z4.string(),
description: z4.string().optional(),
type: flagTypeSchema.default("boolean"),
enabled: z4.boolean().default(false),
defaultValue: z4.any().optional(),
rolloutPercentage: z4.number().min(0).max(100).default(0),
variants: z4.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: z4.record(z4.string(), z4.any()).optional()
});
var evaluationContextSchema = z4.object({
userId: z4.string().optional(),
email: z4.string().optional(),
role: z4.string().optional(),
organizationId: z4.string().optional(),
attributes: z4.record(z4.string(), z4.any()).optional()
});
var selectSchema = z4.union([
z4.enum(["value", "full"]),
z4.array(z4.enum(["value", "variant", "reason", "metadata"])).min(1).max(4)
]);
var environmentParamSchema = z4.string().min(1).max(64).refine((val) => /^[a-zA-Z0-9._-]+$/.test(val), {
message: "Environment must contain only alphanumeric characters, dots, underscores, and hyphens"
});
var shapeModeSchema = z4.enum(["value", "full"]);
var fieldsSchema = z4.array(z4.enum(["value", "variant", "reason", "metadata"])).min(1).max(4);
var flagOverrideInputSchema = z4.object({
flagId: z4.string(),
userId: z4.string(),
value: z4.any(),
enabled: z4.boolean().default(true),
variant: z4.string().optional(),
reason: z4.string().optional(),
expiresAt: z4.date().optional()
});
var flagOverrideUpsertSchema = flagOverrideInputSchema.extend({
id: z4.string().optional()
});
var flagEvaluationInputSchema = z4.object({
flagId: z4.string(),
userId: z4.string().optional(),
sessionId: z4.string().optional(),
value: z4.any(),
variant: z4.string().optional(),
reason: evaluationReasonSchema.optional(),
context: z4.record(z4.string(), z4.any()).optional()
});
var flagAuditInputSchema = z4.object({
flagId: z4.string(),
userId: z4.string().optional(),
action: auditActionSchema,
previousValue: z4.any().optional(),
newValue: z4.any().optional(),
metadata: z4.record(z4.string(), z4.any()).optional()
});
var flagEventInputSchema = z4.object({
flagKey: z4.string().describe("The feature flag key that was used"),
event: z4.string().describe("The event name to track"),
properties: z4.union([z4.number(), z4.record(z4.string(), z4.any())]).optional(),
timestamp: z4.string().optional().describe("RFC3339 timestamp string"),
sampleRate: z4.number().min(0).max(1).optional().describe("Client-side sampling rate (0-1). Server may clamp/override.")
});
var flagEventBatchInputSchema = z4.object({
events: z4.array(flagEventInputSchema).min(1).max(100).describe("Array of events to track (max 100 per batch)"),
sampleRate: z4.number().min(0).max(1).optional().describe(
"Default sampling rate applied to entire batch if individual events don't specify sampleRate"
),
idempotencyKey: z4.string().optional().describe(
"Optional idempotency key for preventing duplicate batch processing"
)
});
// src/endpoints/admin/flags.ts
function createAdminFlagsEndpoints(pluginContext) {
const listFeatureFlagsHandler = createAuthEndpoint4(
"/feature-flags/admin/flags",
{
method: "GET",
use: [sessionMiddleware4],
query: z5.object({
organizationId: z5.string().optional(),
cursor: z5.string().optional(),
limit: z5.coerce.number().min(1).max(100).optional(),
q: z5.string().optional(),
sort: z5.string().optional(),
// e.g., "-updatedAt", "key"
type: z5.enum(["boolean", "string", "number", "json"]).optional(),
enabled: z5.coerce.boolean().optional(),
prefix: z5.string().optional(),
include: z5.enum(["stats"]).optional()
}).optional(),
metadata: {
openapi: {
operationId: "auth.api.listFeatureFlags",
summary: "List Feature Flags",
description: "List feature flags with pagination, search, filtering, and sorting (admin only). Supports type, enabled, and prefix filters."
}
}
},
async (ctx) => {
try {
const {
organizationId,
cursor,
limit,
q,
sort,
type,
enabled,
prefix,
include
} = ctx.query || {};
const orgResult = resolveEffectiveOrgId(
ctx,
pluginContext,
organizationId
);
if (!orgResult.ok) return orgResult.response;
const effectiveOrgId = orgResult.organizationId;
const pageSize = limit ?? 50;
const decodeCursor = /* @__PURE__ */ __name((c) => {
if (!c) return 0;
try {
const s = Buffer.from(c, "base64").toString("utf8");
const n = Number(s);
return Number.isFinite(n) && n >= 0 ? n : 0;
} catch {
return 0;
}
}, "decodeCursor");
const encodeCursor = /* @__PURE__ */ __name((n) => Buffer.from(String(n), "utf8").toString("base64"), "encodeCursor");
const offset = decodeCursor(cursor);
const applyIncludeStats = /* @__PURE__ */ __name(async (items) => {
if (include !== "stats") return items;
return Promise.all(
items.map(async (flag) => {
const stats = await pluginContext.storage.getEvaluationStats(
flag.id
);
return { ...flag, stats };
})
);
}, "applyIncludeStats");
const parseSort = /* @__PURE__ */ __name((s) => {
if (!s) return {};
const desc = s.startsWith("-");
const field = desc ? s.slice(1) : s;
if (!field) return {};
return {
orderBy: field,
orderDirection: desc ? "desc" : "asc"
};
}, "parseSort");
const sortSpec = parseSort(sort);
const buildFilters = /* @__PURE__ */ __name((flags2) => {
let filtered = flags2;
if (q && q.trim().length > 0) {
const needle = q.toLowerCase();
filtered = filtered.filter(
(f) => (f.key || "").toLowerCase().includes(needle) || (f.name || "").toLowerCase().includes(needle)
);
}
if (type) {
filtered = filtered.filter((f) => f.type === type);
}
if (enabled !== void 0) {
filtered = filtered.filter((f) => f.enabled === enabled);
}
if (prefix) {
filtered = filtered.filter(
(f) => (f.key || "").toLowerCase().startsWith(prefix.toLowerCase())
);
}
return filtered;
}, "buildFilters");
const needsInMemoryFiltering = q || type || enabled !== void 0 || prefix;
if (needsInMemoryFiltering) {
const all = await pluginContext.storage.listFlags(effectiveOrgId);
const filtered = buildFilters(all);
const sorted = sortSpec.orderBy ? [...filtered].sort((a, b) => {
const dir = (sortSpec.orderDirection || "asc") === "desc" ? -1 : 1;
const av = a[sortSpec.orderBy];
const bv = b[sortSpec.orderBy];
if (av === bv) return 0;
return av > bv ? dir : -dir;
}) : filtered;
const page = sorted.slice(offset, offset + pageSize);
const withStats2 = await applyIncludeStats(page);
const hasMore2 = offset + pageSize < sorted.length;
const nextCursor2 = hasMore2 ? encodeCursor(offset + pageSize) : void 0;
return ctx.json({
flags: withStats2,
page: {
nextCursor: nextCursor2,
limit: pageSize,
hasMore: hasMore2
}
});
}
const filter = {};
if (type) filter.type = type;
if (enabled !== void 0) filter.enabled = enabled;
if (prefix) filter.keyPrefix = prefix;
const flags = await pluginContext.storage.listFlags(effectiveOrgId, {
limit: pageSize,
offset,
orderBy: sortSpec.orderBy,
orderDirection: sortSpec.orderDirection,
filter: Object.keys(filter).length > 0 ? filter : void 0
});
const withStats = await applyIncludeStats(flags);
const hasMore = flags.length === pageSize;
const nextCursor = hasMore ? encodeCursor(offset + pageSize) : void 0;
return ctx.json({
flags: withStats,
page: {
nextCursor,
limit: pageSize,
hasMore
}
});
} catch (error) {
console.error("[feature-flags] Error listing flags:", error);
return jsonError(ctx, "STORAGE_ERROR", "Failed to list flags", 500);
}
}
);
const createFeatureFlagHandler = createAuthEndpoint4(
"/feature-flags/admin/flags",
{
method: "POST",
use: [sessionMiddleware4],
body: featureFlagInputSchema.extend({
organizationId: z5.string().optional()
// Multi-tenant org ID
}),
metadata: {
openapi: {
operationId: "auth.api.createFeatureFlag",
summary: "Create Feature Flag",
description: "Create a new feature flag (admin only)"
}
}
},
async (ctx) => {
try {
const parseResult = featureFlagInputSchema.extend({
organizationId: z5.string().optional()
}).safeParse(ctx.body);
if (!parseResult.success) {
return jsonError(
ctx,
"INVALID_INPUT",
"Invalid flag data",
400,
parseResult.error.issues
);
}
const flagData = parseResult.data;
if (pluginContext.config.multiTenant.enabled && !flagData.organizationId) {
const session = ctx.context.session;
if (session?.user?.organizationId) {
flagData.organizationId = session.user.organizationId;
} else {
return jsonError(
ctx,
"ORGANIZATION_REQUIRED",
"Organization ID is required when multi-tenant is enabled",
400
);
}
}
const flag = await pluginContext.storage.createFlag(flagData);
return ctx.json(flag);
} catch (error) {
console.error("[feature-flags] Error creating flag:", error);
const errorMessage = error instanceof Error ? error.message : "Storage operation failed";
const isValidationError = errorMessage.includes("duplicate") || errorMessage.includes("unique") || errorMessage.includes("constraint");
return jsonError(
ctx,
"STORAGE_ERROR",
isValidationError ? "Flag key already exists" : "Storage operation failed",
isValidationError ? 409 : 500
);
}
}
);
const updateFeatureFlagHandler = createAuthEndpoint4(
"/feature-flags/admin/flags/:id",
{
method: "PATCH",
use: [sessionMiddleware4],
body: featureFlagInputSchema.partial(),
// Reuse schema, allow partial updates
metadata: {
openapi: {
operationId: "auth.api.updateFeatureFlag",
summary: "Update Feature Flag",
description: "Update an existing feature flag (admin only)"
}
}
},
async (ctx) => {
try {
const id = ctx.params?.id;
const updates = ctx.body;
if (pluginContext.config.multiTenant.enabled) {
const res = await ensureFlagOwnership(ctx, pluginContext, id);
if (!res.ok) return res.response;
}
const flag = await pluginContext.storage.updateFlag(id, updates);
return ctx.json(flag);
} catch (error) {
console.error("[feature-flags] Error updating flag:", error);
return jsonError(ctx, "STORAGE_ERROR", "Failed to update flag", 500);
}
}
);
const deleteFeatureFlagHandler = createAuthEndpoint4(
"/feature-flags/admin/flags/:id",
{
method: "DELETE",
use: [sessionMiddleware4],
metadata: {
openapi: {
operationId: "auth.api.deleteFeatureFlag",
summary: "Delete Feature Flag",
description: "Delete a feature flag (admin only)"
}
}
},
async (ctx) => {
try {
const id = ctx.params?.id;
if (pluginContext.config.multiTenant.enabled) {
const res = await ensureFlagOwnership(ctx, pluginContext, id);
if (!res.ok) return res.response;
}
await pluginContext.storage.deleteFlag(id);
return new Response(null, { status: 204 });
} catch (error) {
console.error("[feature-flags] Error deleting flag:", error);
return jsonError(ctx, "STORAGE_ERROR", "Failed to delete flag", 500);
}
}
);
const getFeatureFlagHandler = createAuthEndpoint4(
"/feature-flags/admin/flags/:id",
{
method: "GET",
use: [sessionMiddleware4],
metadata: {
openapi: {
operationId: "auth.api.getFeatureFlag",
summary: "Get Feature Flag",
description: "Get a specific feature flag (admin only)"
}
}
},
async (ctx) => {
try {
const id = ctx.params?.id;
if (pluginContext.config.multiTenant.enabled) {
const res = await ensureFlagOwnership(ctx, pluginContext, id);
if (!res.ok) return res.response;
}
const flag = await pluginContext.storage.getFlag(id);
if (!flag) {
return jsonError(ctx, "NOT_FOUND", "Flag not found", 404);
}
return ctx.json(flag);
} catch (error) {
console.error("[feature-flags] Error getting flag:", error);
return jsonError(ctx, "STORAGE_ERROR", "Failed to get flag", 500);
}
}
);
const enableFeatureFlagHandler = createAuthEndpoint4(
"/feature-flags/admin/flags/:id/enable",
{
method: "POST",
use: [sessionMiddleware4],
metadata: {
openapi: {
operationId: "auth.api.enableFeatureFlag",
summary: "Enable Feature Flag",
description: "Enable a feature flag (admin only)"
}
}
},
async (ctx) => {
try {
const id = ctx.params?.id;
if (pluginContext.config.multiTenant.enabled) {
const res = await ensureFlagOwnership(ctx, pluginContext, id);
if (!res.ok) return res.response;
}
const flag = await pluginContext.storage.updateFlag(id, {
enabled: true
});
return ctx.json(flag);
} catch (error) {
console.error("[feature-flags] Error enabling flag:", error);
return jsonError(ctx, "STORAGE_ERROR", "Failed to enable flag", 500);
}
}
);
const disableFeatureFlagHandler = createAuthEndpoint4(
"/feature-flags/admin/flags/:id/disable",
{
method: "POST",
use: [sessionMiddleware4],
metadata: {
openapi: {
operationId: "auth.api.disableFeatureFlag",
summary: "Disable Feature Flag",
description: "Disable a feature flag (admin only)"
}
}
},
async (ctx) => {
try {
const id = ctx.params?.id;
if (pluginContext.config.multiTenant.enabled) {
const res = await ensureFlagOwnership(ctx, pluginContext, id);
if (!res.ok) return res.response;
}
const flag = await pluginContext.storage.updateFlag(id, {
enabled: false
});
return ctx.json(flag);
} catch (error) {
console.error("[feature-flags] Error disabling flag:", error);
return jsonError(ctx, "STORAGE_ERROR", "Failed to disable flag", 500);
}
}
);
return {
// Flags CRUD + enable/disable
listFeatureFlags: listFeatureFlagsHandler,
createFeatureFlag: createFeatureFlagHandler,
getFeatureFlag: getFeatureFlagHandler,
updateFeatureFlag: updateFeatureFlagHandler,
deleteFeatureFlag: deleteFeatureFlagHandler,
enableFeatureFlag: enableFeatureFlagHandler,
disableFeatureFlag: disableFeatureFlagHandler
};
}
__name(createAdminFlagsEndpoints, "createAdminFlagsEndpoints");
// src/endpoints/admin/overrides.ts
import { createAuthEndpoint as createAuthEndpoint5, sessionMiddleware as sessionMiddleware5 } from "better-auth/api";
import { z as z6 } from "zod";
function createAdminOverridesEndpoints(pluginContext) {
const listFeatureFlagOverridesHandler = createAuthEndpoint5(
"/feature-flags/admin/overrides",
{
method: "GET",
use: [sessionMiddleware5],
query: z6.object({
organizationId: z6.string().optional(),
cursor: z6.string().optional(),
limit: z6.coerce.number().min(1).max(100).optional(),
q: z6.string().optional(),
sort: z6.string().optional(),
// e.g., "-createdAt", "flagId"
flagId: z6.string().optional(),
userId: z6.string().optional()
}).optional(),
metadata: {
openapi: {
operationId: "auth.api.listFeatureFlagOverrides",
summary: "List Flag Overrides",
description: "Get flag overrides (admin only)"
}
}
},
async (ctx) => {
try {
const { organizationId, cursor, limit, q, sort, flagId, userId } = ctx.query || {};
if (pluginContext.config.multiTenant.enabled && flagId) {
const res = await ensureFlagOwnership(ctx, pluginContext, flagId);
if (!res.ok) return res.response;
}
const pageSize = limit ?? 50;
const decodeCursor = /* @__PURE__ */ __name((c) => {
if (!c) return 0;
try {
const s = Buffer.from(c, "base64").toString("utf8");
const n = Number(s);
return Number.isFinite(n) && n >= 0 ? n : 0;
} catch {
return 0;
}
}, "decodeCursor");
const encodeCursor = /* @__PURE__ */ __name((n) => Buffer.from(String(n), "utf8").toString("base64"), "encodeCursor");
const offset = decodeCursor(cursor);
const parseSort = /* @__PURE__ */ __name((s) => {
if (!s) return {};
const desc = s.startsWith("-");
const field = desc ? s.slice(1) : s;
if (!field) return {};
return {
orderBy: field,
orderDirection: desc ? "desc" : "asc"
};
}, "parseSort");
const sortSpec = parseSort(sort);
const buildFilters = /* @__PURE__ */ __name((overrides2) => {
let filtered2 = overrides2;
if (q && q.trim().length > 0) {
const needle = q.toLowerCase();
filtered2 = filtered2.filter(
(o) => (o.flagId || "").toLowerCase().includes(needle) || (o.userId || "").toLowerCase().includes(needle)
);
}
return filtered2;
}, "buildFilters");
const overrides = await pluginContext.storage.listOverrides(
flagId,
userId
);
const filtered = buildFilters(overrides);
const sorted = sortSpec.orderBy ? [...filtered].sort((a, b) => {
const dir = (sortSpec.orderDirection || "asc") === "desc" ? -1 : 1;
const av = a[sortSpec.orderBy];
const bv = b[sortSpec.orderBy];
if (av === bv) return 0;
return av > bv ? dir : -dir;
}) : filtered;
const page = sorted.slice(offset, offset + pageSize);
const hasMore = offset + pageSize < sorted.length;
const nextCursor = hasMore ? encodeCursor(offset + pageSize) : void 0;
return ctx.json({
overrides: page,
page: {
nextCursor,
limit: pageSize,
hasMore
}
});
} catch (error) {
console.error("[feature-flags] Error listing overrides:", error);
return jsonError(ctx, "STORAGE_ERROR", "Failed to list overrides", 500);
}
}
);
const createFeatureFlagOverrideHandler = createAuthEndpoint5(
"/feature-flags/admin/overrides",
{
method: "POST",
use: [sessionMiddleware5],
body: flagOverrideInputSchema.omit({ expiresAt: true }).extend({
expiresAt: z6.string().datetime().optional()
// Accept RFC3339/ISO 8601 string
}),
metadata: {
openapi: {
operationId: "auth.api.createFeatureFlagOverride",
summary: "Create Flag Override",
description: "Create a user override for a feature flag (admin only)"
}
}
},
async (ctx) => {
try {
const overrideData = {
...ctx.body,
expiresAt: ctx.body.expiresAt ? new Date(ctx.body.expiresAt) : void 0
};
if (pluginContext.config.multiTenant.enabled) {
const res = await ensureFlagOwnership(
ctx,
pluginContext,
overrideData.flagId
);
if (!res.ok) return res.response;
}
const override = await pluginContex