better-auth-feature-flags
Version:
Ship features safely with feature flags, A/B testing, and progressive rollouts - Better Auth plugin for modern release management
222 lines (200 loc) • 6.03 kB
text/typescript
// SPDX-FileCopyrightText: 2025-present Kriasoft
// SPDX-License-Identifier: MIT
import * as z from "zod";
/**
* Runtime validation schemas. Rejects invalid input before DB write.
*
* @invariant Flag keys: /^[a-z0-9-_]+$/i (URL-safe)
* @invariant Percentages: 0 ≤ n ≤ 100
* @invariant Variant weights: Σ(weights) = 100 ± 0.01
* @invariant Priority: integer, -1000 ≤ n ≤ 1000
*
* @decision 0.01 tolerance handles IEEE 754 precision
* @decision Empty variants allowed (feature without A/B test)
*/
// Enum schemas
export const flagTypeSchema = z.enum(["boolean", "string", "number", "json"]);
export const evaluationReasonSchema = z.enum([
"rule_match",
"override",
"percentage_rollout",
"default",
"disabled",
"not_found",
]);
export const auditActionSchema = z.enum([
"created",
"updated",
"deleted",
"enabled",
"disabled",
"rule_added",
"rule_updated",
"rule_deleted",
"override_added",
"override_removed",
]);
export const conditionOperatorSchema = z.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",
]);
// Condition schemas
export const conditionSchema = z.object({
attribute: z.string(),
operator: conditionOperatorSchema,
value: z.any(),
});
// Nested targeting conditions: NOT → ALL → ANY precedence; {} matches all
export const ruleConditionsSchema: z.ZodType<{
all?: z.infer<typeof conditionSchema>[];
any?: z.infer<typeof conditionSchema>[];
not?: any;
}> = z.object({
all: z.array(conditionSchema).optional(),
any: z.array(conditionSchema).optional(),
not: z.lazy(() => ruleConditionsSchema).optional(),
});
// Input validation schemas
export const flagRuleInputSchema = z.object({
name: z.string().optional(),
priority: z.number().default(0),
conditions: ruleConditionsSchema,
value: z.any(),
percentage: z.number().min(0).max(100).optional(),
enabled: z.boolean().default(true),
});
// Variant weight distribution
export const variantSchema = z.object({
key: z.string(),
value: z.any(),
weight: z.number().min(0).max(100),
metadata: z.record(z.string(), z.any()).optional(),
});
export const featureFlagInputSchema = z.object({
key: z.string().refine((val) => /^[a-z0-9-_]+$/i.test(val), {
message:
"Key must contain only alphanumeric characters, hyphens, and underscores",
}),
name: z.string(),
description: z.string().optional(),
type: flagTypeSchema.default("boolean"),
enabled: z.boolean().default(false),
defaultValue: z.any().optional(),
rolloutPercentage: z.number().min(0).max(100).default(0),
variants: z
.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: z.record(z.string(), z.any()).optional(),
});
export const evaluationContextSchema = z.object({
userId: z.string().optional(),
email: z.string().optional(),
role: z.string().optional(),
organizationId: z.string().optional(),
attributes: z.record(z.string(), z.any()).optional(),
});
// Common parameter schemas for API
export const selectSchema = z.union([
z.enum(["value", "full"]),
z
.array(z.enum(["value", "variant", "reason", "metadata"]))
.min(1)
.max(4),
]);
export const environmentParamSchema = z
.string()
.min(1)
.max(64)
.refine((val) => /^[a-zA-Z0-9._-]+$/.test(val), {
message:
"Environment must contain only alphanumeric characters, dots, underscores, and hyphens",
});
// Legacy schemas for backward compatibility (deprecated)
export const shapeModeSchema = z.enum(["value", "full"]);
export const fieldsSchema = z
.array(z.enum(["value", "variant", "reason", "metadata"]))
.min(1)
.max(4);
export const flagOverrideInputSchema = z.object({
flagId: z.string(),
userId: z.string(),
value: z.any(),
enabled: z.boolean().default(true),
variant: z.string().optional(),
reason: z.string().optional(),
expiresAt: z.date().optional(),
});
// Upsert schema with optional ID
export const flagOverrideUpsertSchema = flagOverrideInputSchema.extend({
id: z.string().optional(),
});
export const flagEvaluationInputSchema = z.object({
flagId: z.string(),
userId: z.string().optional(),
sessionId: z.string().optional(),
value: z.any(),
variant: z.string().optional(),
reason: evaluationReasonSchema.optional(),
context: z.record(z.string(), z.any()).optional(),
});
export const flagAuditInputSchema = z.object({
flagId: z.string(),
userId: z.string().optional(),
action: auditActionSchema,
previousValue: z.any().optional(),
newValue: z.any().optional(),
metadata: z.record(z.string(), z.any()).optional(),
});
// Event tracking schemas
export const flagEventInputSchema = z.object({
flagKey: z.string().describe("The feature flag key that was used"),
event: z.string().describe("The event name to track"),
properties: z.union([z.number(), z.record(z.string(), z.any())]).optional(),
timestamp: z.string().optional().describe("RFC3339 timestamp string"),
sampleRate: z
.number()
.min(0)
.max(1)
.optional()
.describe("Client-side sampling rate (0-1). Server may clamp/override."),
});
export const flagEventBatchInputSchema = z.object({
events: z
.array(flagEventInputSchema)
.min(1)
.max(100)
.describe("Array of events to track (max 100 per batch)"),
sampleRate: z
.number()
.min(0)
.max(1)
.optional()
.describe(
"Default sampling rate applied to entire batch if individual events don't specify sampleRate",
),
idempotencyKey: z
.string()
.optional()
.describe(
"Optional idempotency key for preventing duplicate batch processing",
),
});