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

269 lines (254 loc) 9.8 kB
// SPDX-FileCopyrightText: 2025-present Kriasoft // SPDX-License-Identifier: MIT import type { BetterAuthPlugin } from "better-auth"; import { createAuthEndpoint, sessionMiddleware } from "better-auth/api"; import { z } from "zod"; import { flagRuleInputSchema } from "../../schema/validation"; import type { PluginContext } from "../../types"; import { ensureFlagOwnership, jsonError } from "../shared"; /** Better Auth plugin endpoints type, avoids import cycles with endpoints/index.ts */ export type FlagEndpoints = NonNullable<BetterAuthPlugin["endpoints"]>; /** * Creates admin endpoints for feature flag rules management. * * REST API: * - GET /admin/flags/:flagId/rules - List rules for flag * - POST /admin/flags/:flagId/rules - Create new rule * - GET /admin/flags/:flagId/rules/:ruleId - Get specific rule * - PATCH /admin/flags/:flagId/rules/:ruleId - Update rule * - DELETE /admin/flags/:flagId/rules/:ruleId - Delete rule * - POST /admin/flags/:flagId/rules/reorder - Reorder rules * * Rules always scoped to parent flag for security and logical organization. * * PRIORITY: Rules evaluated in priority order (1 = highest). * Reorder endpoint enables bulk priority updates for predictable evaluation. * * @param pluginContext - Plugin context with DB, config, and utilities * @returns Rule management endpoints with validation * @see plugins/feature-flags/src/endpoints/shared.ts * @see plugins/feature-flags/src/schema/validation.ts */ export function createAdminRulesEndpoints( pluginContext: PluginContext, ): FlagEndpoints { // GET /feature-flags/admin/flags/{flagId}/rules (canonical RESTful) const listFeatureFlagRulesHandler = createAuthEndpoint( "/feature-flags/admin/flags/:flagId/rules", { method: "GET", use: [sessionMiddleware], metadata: { openapi: { operationId: "auth.api.listFeatureFlagRules", summary: "List Flag Rules", description: "Get all rules for a feature flag (admin only)", }, }, }, async (ctx) => { try { const flagId = ctx.params?.flagId; if (pluginContext.config.multiTenant.enabled) { const res = await ensureFlagOwnership(ctx, pluginContext, flagId); if (!res.ok) return res.response; } const rules = await pluginContext.storage.getRulesForFlag(flagId); return ctx.json({ rules }); } catch (error) { console.error("[feature-flags] Error listing rules:", error); return jsonError(ctx, "STORAGE_ERROR", "Failed to list rules", 500); } }, ); // POST /feature-flags/admin/flags/{flagId}/rules (canonical RESTful) const createFeatureFlagRuleHandler = createAuthEndpoint( "/feature-flags/admin/flags/:flagId/rules", { method: "POST", use: [sessionMiddleware], body: flagRuleInputSchema.omit({ name: true }), // API rules don't require names metadata: { openapi: { operationId: "auth.api.createFeatureFlagRule", summary: "Create Flag Rule", description: "Create a new rule for a feature flag (admin only)", }, }, }, async (ctx) => { try { const flagId = ctx.params?.flagId; if (pluginContext.config.multiTenant.enabled) { const res = await ensureFlagOwnership(ctx, pluginContext, flagId); if (!res.ok) return res.response; } const ruleData = { ...ctx.body, flagId, enabled: true }; const rule = await pluginContext.storage.createRule(ruleData); return ctx.json(rule); } catch (error) { console.error("[feature-flags] Error creating rule:", error); return jsonError(ctx, "STORAGE_ERROR", "Failed to create rule", 500); } }, ); // GET /feature-flags/admin/flags/{flagId}/rules/{ruleId} (canonical RESTful) const getFeatureFlagRuleHandler = createAuthEndpoint( "/feature-flags/admin/flags/:flagId/rules/:ruleId", { method: "GET", use: [sessionMiddleware], metadata: { openapi: { operationId: "auth.api.getFeatureFlagRule", summary: "Get Flag Rule", description: "Get a specific rule for a feature flag (admin only)", }, }, }, async (ctx) => { try { const { flagId, ruleId } = ctx.params || {}; if (pluginContext.config.multiTenant.enabled) { const res = await ensureFlagOwnership(ctx, pluginContext, flagId); if (!res.ok) return res.response; } // Fetch flag rules and find specific rule by ID const rules = await pluginContext.storage.getRulesForFlag(flagId); const rule = rules.find((r) => r.id === ruleId); if (!rule) { return jsonError(ctx, "NOT_FOUND", "Rule not found", 404); } return ctx.json(rule); } catch (error) { console.error("[feature-flags] Error getting rule:", error); return jsonError(ctx, "STORAGE_ERROR", "Failed to get rule", 500); } }, ); // PATCH /feature-flags/admin/flags/{flagId}/rules/{ruleId} (canonical RESTful) const updateFeatureFlagRuleHandler = createAuthEndpoint( "/feature-flags/admin/flags/:flagId/rules/:ruleId", { method: "PATCH", use: [sessionMiddleware], body: flagRuleInputSchema.omit({ name: true }).partial(), // Partial updates for rules metadata: { openapi: { operationId: "auth.api.updateFeatureFlagRule", summary: "Update Flag Rule", description: "Update a rule for a feature flag (admin only)", }, }, }, async (ctx) => { try { const { flagId, ruleId } = ctx.params || {}; if (pluginContext.config.multiTenant.enabled) { const res = await ensureFlagOwnership(ctx, pluginContext, flagId); if (!res.ok) return res.response; } const rule = await pluginContext.storage.updateRule(ruleId, ctx.body); return ctx.json(rule); } catch (error) { console.error("[feature-flags] Error updating rule:", error); return jsonError(ctx, "STORAGE_ERROR", "Failed to update rule", 500); } }, ); // DELETE /feature-flags/admin/flags/{flagId}/rules/{ruleId} (canonical RESTful) const deleteFeatureFlagRuleHandler = createAuthEndpoint( "/feature-flags/admin/flags/:flagId/rules/:ruleId", { method: "DELETE", use: [sessionMiddleware], metadata: { openapi: { operationId: "auth.api.deleteFeatureFlagRule", summary: "Delete Flag Rule", description: "Delete a rule for a feature flag (admin only)", }, }, }, async (ctx) => { try { const { flagId, ruleId } = ctx.params || {}; if (pluginContext.config.multiTenant.enabled) { const res = await ensureFlagOwnership(ctx, pluginContext, flagId); if (!res.ok) return res.response; } await pluginContext.storage.deleteRule(ruleId); return new Response(null, { status: 204 }); } catch (error) { console.error("[feature-flags] Error deleting rule:", error); return jsonError(ctx, "STORAGE_ERROR", "Failed to delete rule", 500); } }, ); // POST /feature-flags/admin/flags/{flagId}/rules/reorder (canonical RESTful) const reorderFeatureFlagRulesHandler = createAuthEndpoint( "/feature-flags/admin/flags/:flagId/rules/reorder", { method: "POST", use: [sessionMiddleware], body: z.object({ ids: z.array(z.string()).describe("Array of rule IDs in new order"), }), metadata: { openapi: { operationId: "auth.api.reorderFeatureFlagRules", summary: "Reorder Flag Rules", description: "Reorder rules for a feature flag (admin only)", }, }, }, async (ctx) => { try { const flagId = ctx.params?.flagId; const { ids } = ctx.body; if (pluginContext.config.multiTenant.enabled) { const res = await ensureFlagOwnership(ctx, pluginContext, flagId); if (!res.ok) return res.response; } // SECURITY: Validate all rule IDs belong to this flag const existingRules = await pluginContext.storage.getRulesForFlag(flagId); const existingIds = new Set(existingRules.map((r) => r.id)); const providedIds = new Set(ids); if ( existingIds.size !== providedIds.size || !Array.from(existingIds).every((id) => providedIds.has(id)) ) { return jsonError( ctx, "INVALID_INPUT", "Rule IDs don't match existing rules", 400, ); } // Priority assignment: index + 1 (1 = highest priority) const updates = ids.map((id, index) => ({ id, priority: index + 1 })); await Promise.all( updates.map(({ id, priority }) => pluginContext.storage.updateRule(id, { priority }), ), ); const reorderedRules = await pluginContext.storage.getRulesForFlag(flagId); return ctx.json({ rules: reorderedRules }); } catch (error) { console.error("[feature-flags] Error reordering rules:", error); return jsonError(ctx, "STORAGE_ERROR", "Failed to reorder rules", 500); } }, ); return { // Rules CRUD + reorder listFeatureFlagRules: listFeatureFlagRulesHandler, createFeatureFlagRule: createFeatureFlagRuleHandler, getFeatureFlagRule: getFeatureFlagRuleHandler, updateFeatureFlagRule: updateFeatureFlagRuleHandler, deleteFeatureFlagRule: deleteFeatureFlagRuleHandler, reorderFeatureFlagRules: reorderFeatureFlagRulesHandler, } as FlagEndpoints; }