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,494 lines (1,485 loc) 185 kB
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