feat(flags): production-grade feature flag system — multi-variate, segments, audit, SSE, scheduling, prerequisites

- types.ts: multi-variate flags (boolean/string/number/JSON), targeting rules with 18 operators, scheduling (enableAt/disableAt/gradual rollout), prerequisites, segments, audit log, evaluation context
- evaluator.ts: pure evaluation engine — schedule checking, prerequisite dependencies (circular detection), individual targeting, targeting rules (AND clauses), segment matching, percentage rollout (FNV-1a), OS version/platform/region filtering
- repository.ts: 3 collections — feature_flags, flag_segments, flag_audit_log
- routes.ts: 18 endpoints — flag CRUD, toggle, archive, kill switch (with tag filter), segment CRUD, audit log, POST /flags/evaluate (multi-variate), SSE /flags/stream, legacy /flags/poll backward-compat
- seed.ts: updated to produce full FeatureFlagDoc with variations, version
- flags.test.ts: 63 tests — schema validation, evaluator engine, targeting rules, segments, prerequisites, scheduling, gradual rollouts, multi-variate, version comparison, deterministic hashing
- @bytelyst/events: added flag.created, flag.updated, flag.deleted, flag.kill_switch event types
- @bytelyst/feature-flag-client: multi-variate support (getValue, getEvaluation, getAllEvaluations), SSE streaming mode, onChange listeners, auth token injection
- event-dispatcher.ts + webhooks/types.ts: wired new flag events
This commit is contained in:
saravanakumardb1 2026-03-21 11:44:49 -07:00
parent d11f84da5f
commit ca6a4d41d8
12 changed files with 2320 additions and 226 deletions

View File

@ -148,6 +148,29 @@ export const PlatformEventSchemas = {
enabled: z.boolean(),
percentage: z.number().optional(),
productId: z.string(),
flagKey: z.string().optional(),
actor: z.string().optional(),
}),
'flag.created': z.object({
productId: z.string(),
flagKey: z.string(),
actor: z.string().optional(),
}),
'flag.updated': z.object({
productId: z.string(),
flagKey: z.string(),
actor: z.string().optional(),
changes: z.array(z.string()).optional(),
}),
'flag.deleted': z.object({
productId: z.string(),
flagKey: z.string(),
actor: z.string().optional(),
}),
'flag.kill_switch': z.object({
productId: z.string(),
disabled: z.array(z.string()),
actor: z.string().optional(),
}),
// License events

View File

@ -1,8 +1,12 @@
/**
* Browser/React Native-safe feature flag client for platform-service.
*
* Polls GET /api/flags/poll on a configurable interval and caches results.
* No Node.js dependencies uses globalThis.fetch.
* Supports two modes:
* 1. **Polling** (default) GET /flags/poll on a configurable interval
* 2. **Streaming** SSE via GET /flags/stream for real-time updates
*
* Both modes support multi-variate evaluation via POST /flags/evaluate.
* No Node.js dependencies uses globalThis.fetch and EventSource.
*
* @example
* ```ts
@ -15,11 +19,20 @@
* });
*
* await flags.init({ userId: 'user-123' });
*
* // Boolean check (legacy)
* if (flags.isEnabled('premium_body_viz')) { ... }
*
* // Multi-variate
* const color = flags.getValue('cta_color', '#000000');
* const config = flags.getValue('rate_limits', { maxReqs: 100 });
*
* // Listen for changes (SSE or polling refresh)
* const unsub = flags.onChange((key) => console.log(`${key} changed`));
* ```
*/
import type { FeatureFlagClient, FeatureFlagClientConfig } from './types.js';
import type { FeatureFlagClient, FeatureFlagClientConfig, EvaluationResult } from './types.js';
export function createFeatureFlagClient(config: FeatureFlagClientConfig): FeatureFlagClient {
const {
@ -29,87 +42,254 @@ export function createFeatureFlagClient(config: FeatureFlagClientConfig): Featur
pollIntervalMs = 5 * 60 * 1000,
storage,
storagePrefix,
useStreaming = false,
getAccessToken,
} = config;
const prefix = storagePrefix ?? productId;
const STORAGE_KEY = `${prefix}-feature-flags`;
const BOOL_KEY = `${prefix}-feature-flags`;
const EVAL_KEY = `${prefix}-feature-evals`;
let flags: Record<string, boolean> = {};
let boolFlags: Record<string, boolean> = {};
let evaluations: Record<string, EvaluationResult> = {};
let initialized = false;
let intervalId: ReturnType<typeof setInterval> | null = null;
// eslint-disable-next-line no-undef
let eventSource: InstanceType<typeof EventSource> | null = null;
let userId: string | undefined;
const listeners = new Set<(flagKey: string) => void>();
// Restore from storage on creation
if (storage) {
try {
const cached = storage.getItem(STORAGE_KEY);
if (cached) flags = JSON.parse(cached);
const cached = storage.getItem(BOOL_KEY);
if (cached) boolFlags = JSON.parse(cached);
} catch {
// Ignore parse errors
/* Ignore parse errors */
}
try {
const cached = storage.getItem(EVAL_KEY);
if (cached) evaluations = JSON.parse(cached);
} catch {
/* Ignore parse errors */
}
}
async function fetchFlags(): Promise<void> {
function buildHeaders(): Record<string, string> {
const requestId =
typeof globalThis.crypto?.randomUUID === 'function'
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const headers: Record<string, string> = {
'x-product-id': productId,
'x-request-id': requestId,
};
if (getAccessToken) {
const token = getAccessToken();
if (token) headers['authorization'] = `Bearer ${token}`;
}
return headers;
}
function notifyListeners(flagKey: string): void {
for (const listener of listeners) {
try {
listener(flagKey);
} catch {
/* best-effort */
}
}
}
function persistToStorage(): void {
if (!storage) return;
try {
storage.setItem(BOOL_KEY, JSON.stringify(boolFlags));
} catch {
/* non-fatal */
}
try {
storage.setItem(EVAL_KEY, JSON.stringify(evaluations));
} catch {
/* non-fatal */
}
}
async function fetchBoolFlags(): Promise<void> {
try {
const parts = [`platform=${encodeURIComponent(platform)}`];
if (userId) parts.push(`userId=${encodeURIComponent(userId)}`);
const requestId =
typeof globalThis.crypto?.randomUUID === 'function'
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const res = await globalThis.fetch(`${baseUrl}/flags/poll?${parts.join('&')}`, {
headers: { 'x-product-id': productId, 'x-request-id': requestId },
headers: buildHeaders(),
});
if (!res.ok) return;
const data = (await res.json()) as { flags?: Record<string, boolean> };
flags = data.flags ?? {};
const prev = boolFlags;
boolFlags = data.flags ?? {};
// Persist to storage
if (storage) {
try {
storage.setItem(STORAGE_KEY, JSON.stringify(flags));
} catch {
// Storage write failure — non-fatal
}
// Detect changes and notify
const allKeys = new Set([...Object.keys(prev), ...Object.keys(boolFlags)]);
for (const key of allKeys) {
if (prev[key] !== boolFlags[key]) notifyListeners(key);
}
persistToStorage();
} catch {
// Keep existing flags on network error
}
}
async function fetchEvaluations(): Promise<void> {
try {
const body: Record<string, unknown> = { platform };
if (userId) body.userId = userId;
const res = await globalThis.fetch(`${baseUrl}/flags/evaluate`, {
method: 'POST',
headers: { ...buildHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) return;
const data = (await res.json()) as { evaluations?: Record<string, EvaluationResult> };
const prev = evaluations;
evaluations = data.evaluations ?? {};
// Also update bool flags for backward compatibility
for (const [key, result] of Object.entries(evaluations)) {
const newBool =
result.reason !== 'off' &&
result.reason !== 'prerequisite_failed' &&
result.reason !== 'schedule_inactive' &&
result.reason !== 'error';
if (boolFlags[key] !== newBool) {
boolFlags[key] = newBool;
notifyListeners(key);
} else if (JSON.stringify(prev[key]?.value) !== JSON.stringify(result.value)) {
notifyListeners(key);
}
}
persistToStorage();
} catch {
// Keep existing evaluations on network error
}
}
async function fetchAll(): Promise<void> {
await Promise.all([fetchBoolFlags(), fetchEvaluations()]);
}
function startStreaming(): void {
if (typeof globalThis.EventSource === 'undefined') {
// SSE not available (e.g. React Native) — fall back to polling
intervalId = setInterval(() => {
void fetchAll();
}, pollIntervalMs);
return;
}
const url = `${baseUrl}/flags/stream`;
eventSource = new globalThis.EventSource(url);
eventSource.onmessage = event => {
try {
const data = JSON.parse(event.data) as { type?: string; flagKey?: string };
if (data.type === 'flag_change' && data.flagKey) {
// Re-fetch all evaluations on any flag change
void fetchAll().then(() => notifyListeners(data.flagKey!));
}
} catch {
/* Ignore parse errors */
}
};
eventSource.onerror = () => {
// Reconnect handled automatically by EventSource spec
};
}
async function init(params?: { userId?: string }): Promise<void> {
if (initialized) return;
initialized = true;
userId = params?.userId;
await fetchFlags();
intervalId = setInterval(() => {
void fetchFlags();
}, pollIntervalMs);
await fetchAll();
if (useStreaming) {
startStreaming();
} else {
intervalId = setInterval(() => {
void fetchAll();
}, pollIntervalMs);
}
}
function isEnabled(key: string): boolean {
return flags[key] === true;
return boolFlags[key] === true;
}
function getValue<T = boolean | string | number | Record<string, unknown>>(
key: string,
defaultValue: T
): T {
const result = evaluations[key];
if (!result) return defaultValue;
if (result.reason === 'off' || result.reason === 'error') return defaultValue;
return result.value as T;
}
function getEvaluation(key: string): EvaluationResult | undefined {
return evaluations[key];
}
function getAllFlags(): Readonly<Record<string, boolean>> {
return flags;
return boolFlags;
}
function getAllEvaluations(): Readonly<Record<string, EvaluationResult>> {
return evaluations;
}
async function refresh(): Promise<void> {
await fetchFlags();
await fetchAll();
}
function onChange(listener: (flagKey: string) => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function stop(): void {
if (intervalId) clearInterval(intervalId);
intervalId = null;
flags = {};
if (eventSource) {
eventSource.close();
eventSource = null;
}
boolFlags = {};
evaluations = {};
initialized = false;
userId = undefined;
listeners.clear();
}
return { init, isEnabled, getAllFlags, refresh, stop };
return {
init,
isEnabled,
getValue,
getEvaluation,
getAllFlags,
getAllEvaluations,
refresh,
onChange,
stop,
};
}

View File

@ -1,2 +1,7 @@
export { createFeatureFlagClient } from './client.js';
export type { FeatureFlagClient, FeatureFlagClientConfig } from './types.js';
export type {
FeatureFlagClient,
FeatureFlagClientConfig,
EvaluationContext,
EvaluationResult,
} from './types.js';

View File

@ -3,6 +3,27 @@
* Browser/React Native-safe no Node.js dependencies.
*/
// ── Evaluation types ────────────────────────────────────────────────────────
export interface EvaluationContext {
userId?: string;
platform?: string;
region?: string;
osVersion?: string;
appVersion?: string;
email?: string;
custom?: Record<string, string | number | boolean>;
}
export interface EvaluationResult {
key: string;
value: boolean | string | number | Record<string, unknown>;
variationKey: string;
reason: string;
}
// ── Config ──────────────────────────────────────────────────────────────────
export interface FeatureFlagClientConfig {
/** Platform-service base URL (e.g. "http://localhost:4003/api"). */
baseUrl: string;
@ -24,21 +45,44 @@ export interface FeatureFlagClientConfig {
/** Optional storage key prefix. Default: productId. */
storagePrefix?: string;
/** Use SSE for real-time updates instead of polling. Default: false. */
useStreaming?: boolean;
/** Auth token getter for authenticated requests. */
getAccessToken?: () => string | null;
}
// ── Client interface ────────────────────────────────────────────────────────
export interface FeatureFlagClient {
/** Initialize the client: fetch flags immediately and start polling. */
/** Initialize the client: fetch flags immediately and start polling/streaming. */
init(params?: { userId?: string }): Promise<void>;
/** Check if a feature flag is enabled. Returns false if not found. */
/** Check if a boolean feature flag is enabled. Returns false if not found. */
isEnabled(key: string): boolean;
/** Get all currently cached flags. */
/** Get the resolved value of a multi-variate flag. Returns defaultValue if not found. */
getValue<T = boolean | string | number | Record<string, unknown>>(
key: string,
defaultValue: T
): T;
/** Get the full evaluation result for a flag. Returns undefined if not found. */
getEvaluation(key: string): EvaluationResult | undefined;
/** Get all currently cached boolean flags (legacy format). */
getAllFlags(): Readonly<Record<string, boolean>>;
/** Get all evaluation results (multi-variate format). */
getAllEvaluations(): Readonly<Record<string, EvaluationResult>>;
/** Force a refresh of feature flags. */
refresh(): Promise<void>;
/** Stop polling and reset state. */
/** Register a listener for flag changes. Returns unsubscribe function. */
onChange(listener: (flagKey: string) => void): () => void;
/** Stop polling/streaming and reset state. */
stop(): void;
}

View File

@ -115,6 +115,10 @@ export function wireDispatcherToBus(): void {
'job.completed',
'job.failed',
'flag.toggled',
'flag.created',
'flag.updated',
'flag.deleted',
'flag.kill_switch',
'license.activated',
'license.expired',
'invitation.redeemed',

View File

@ -0,0 +1,382 @@
/**
* Flag evaluation engine pure functions, no I/O.
*
* Evaluates a flag against an EvaluationContext and returns the resolved
* variation value + the reason it was chosen. Supports:
*
* - Schedule checking (enableAt / disableAt / gradual rollout)
* - Prerequisite dependencies
* - Individual user targeting
* - Targeting rules with attribute matching (AND within a rule)
* - Segment matching
* - Percentage rollout (deterministic via FNV-1a)
* - OS version range filtering
* - Platform / region filtering
* - Fallback to default variation
*/
import type {
FeatureFlagDoc,
FlagVariation,
TargetingClause,
SegmentDoc,
EvaluationContext,
EvaluationResult,
} from './types.js';
// ── FNV-1a hash ─────────────────────────────────────────────────────────────
/**
* FNV-1a 32-bit hash 099 bucket.
* Deterministic: same (userId, flagKey) always produces the same bucket.
*/
export function hashUserFlag(userId: string, flagKey: string): number {
const input = `${userId}:${flagKey}`;
let hash = 0x811c9dc5; // FNV offset basis
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 0x01000193); // FNV prime
}
return (hash >>> 0) % 100; // 0-99 bucket
}
// ── Semver comparison ───────────────────────────────────────────────────────
/**
* Compare two dot-separated version strings.
* Returns -1 if a < b, 0 if equal, 1 if a > b.
*/
export function compareVersions(a: string, b: string): number {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const na = pa[i] ?? 0;
const nb = pb[i] ?? 0;
if (na < nb) return -1;
if (na > nb) return 1;
}
return 0;
}
// ── Clause evaluation ───────────────────────────────────────────────────────
function getContextAttribute(
ctx: EvaluationContext,
attribute: string
): string | number | boolean | undefined {
switch (attribute) {
case 'userId':
return ctx.userId;
case 'platform':
return ctx.platform;
case 'region':
return ctx.region;
case 'osVersion':
return ctx.osVersion;
case 'appVersion':
return ctx.appVersion;
case 'email':
return ctx.email;
default:
return ctx.custom?.[attribute];
}
}
function evaluateClause(clause: TargetingClause, ctx: EvaluationContext): boolean {
const actual = getContextAttribute(ctx, clause.attribute);
if (actual === undefined) return false;
const strActual = String(actual);
switch (clause.operator) {
case 'eq':
return clause.values.some(v => String(v) === strActual);
case 'neq':
return clause.values.every(v => String(v) !== strActual);
case 'contains':
return clause.values.some(v => strActual.includes(String(v)));
case 'not_contains':
return clause.values.every(v => !strActual.includes(String(v)));
case 'starts_with':
return clause.values.some(v => strActual.startsWith(String(v)));
case 'ends_with':
return clause.values.some(v => strActual.endsWith(String(v)));
case 'gt':
return clause.values.some(v => Number(actual) > Number(v));
case 'gte':
return clause.values.some(v => Number(actual) >= Number(v));
case 'lt':
return clause.values.some(v => Number(actual) < Number(v));
case 'lte':
return clause.values.some(v => Number(actual) <= Number(v));
case 'in':
return clause.values.map(String).includes(strActual);
case 'not_in':
return !clause.values.map(String).includes(strActual);
case 'semver_gt':
return clause.values.some(v => compareVersions(strActual, String(v)) > 0);
case 'semver_gte':
return clause.values.some(v => compareVersions(strActual, String(v)) >= 0);
case 'semver_lt':
return clause.values.some(v => compareVersions(strActual, String(v)) < 0);
case 'semver_lte':
return clause.values.some(v => compareVersions(strActual, String(v)) <= 0);
case 'semver_eq':
return clause.values.some(v => compareVersions(strActual, String(v)) === 0);
case 'regex':
try {
return clause.values.some(v => new RegExp(String(v)).test(strActual));
} catch {
return false;
}
default:
return false;
}
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function getVariation(flag: FeatureFlagDoc, key: string): FlagVariation | undefined {
return flag.variations.find(v => v.key === key);
}
function offResult(flag: FeatureFlagDoc, reason: EvaluationResult['reason']): EvaluationResult {
const variation = getVariation(flag, flag.offVariation);
return {
key: flag.key,
value: variation?.value ?? false,
variationKey: flag.offVariation,
reason,
};
}
function variationResult(
flag: FeatureFlagDoc,
variationKey: string,
reason: EvaluationResult['reason']
): EvaluationResult {
const variation = getVariation(flag, variationKey);
return {
key: flag.key,
value: variation?.value ?? true,
variationKey,
reason,
};
}
// ── Schedule checking ───────────────────────────────────────────────────────
function isScheduleActive(flag: FeatureFlagDoc, now: string): boolean | null {
if (!flag.schedule) return null; // no schedule — use flag.enabled
if (flag.schedule.enableAt && now < flag.schedule.enableAt) return false;
if (flag.schedule.disableAt && now >= flag.schedule.disableAt) return false;
return true;
}
/**
* Calculate the effective rollout percentage considering gradual rollout schedule.
*/
export function getEffectivePercentage(flag: FeatureFlagDoc, now: string): number {
const gr = flag.schedule?.gradualRollout;
if (!gr) return flag.percentage;
if (now < gr.startAt) return gr.startPercentage;
if (now >= gr.endAt) return gr.endPercentage;
// Linear interpolation between start and end
const totalMs = new Date(gr.endAt).getTime() - new Date(gr.startAt).getTime();
const elapsedMs = new Date(now).getTime() - new Date(gr.startAt).getTime();
const progress = Math.min(1, Math.max(0, elapsedMs / totalMs));
return Math.round(gr.startPercentage + (gr.endPercentage - gr.startPercentage) * progress);
}
// ── OS version filtering ────────────────────────────────────────────────────
function matchesOsVersion(flag: FeatureFlagDoc, ctx: EvaluationContext): boolean {
if (!flag.osVersions || flag.osVersions.length === 0) return true;
if (!ctx.platform || !ctx.osVersion) return true; // no context to filter on
const rule = flag.osVersions.find(r => r.platform === ctx.platform);
if (!rule) return false; // osVersions defined but no rule for this platform
if (rule.minVersion && compareVersions(ctx.osVersion, rule.minVersion) < 0) return false;
if (rule.maxVersion && compareVersions(ctx.osVersion, rule.maxVersion) > 0) return false;
return true;
}
// ── Platform / region filtering ─────────────────────────────────────────────
function matchesPlatform(flag: FeatureFlagDoc, ctx: EvaluationContext): boolean {
if (flag.platforms.length === 0) return true;
if (!ctx.platform) return true;
return flag.platforms.includes(ctx.platform);
}
function matchesRegion(flag: FeatureFlagDoc, ctx: EvaluationContext): boolean {
if (!flag.regions || flag.regions.length === 0) return true;
if (!ctx.region) return true;
return flag.regions.includes(ctx.region);
}
// ── Segment matching ────────────────────────────────────────────────────────
function userMatchesSegment(segment: SegmentDoc, ctx: EvaluationContext): boolean {
if (!ctx.userId) return false;
// Explicit exclusion takes priority
if (segment.excludedUsers.includes(ctx.userId)) return false;
// Explicit inclusion
if (segment.includedUsers.includes(ctx.userId)) return true;
// Rule-based matching: all clauses must match (AND)
if (segment.rules.length === 0) return false;
return segment.rules.every(clause => evaluateClause(clause, ctx));
}
// ── Main evaluation function ────────────────────────────────────────────────
export interface EvaluateFlagOptions {
flag: FeatureFlagDoc;
ctx: EvaluationContext;
allFlags: FeatureFlagDoc[];
segments: SegmentDoc[];
now?: string;
/** Guard against circular prerequisites — tracks visited flag keys. */
_visited?: Set<string>;
}
export function evaluateFlag(opts: EvaluateFlagOptions): EvaluationResult {
const { flag, ctx, allFlags, segments, _visited = new Set() } = opts;
const now = opts.now ?? new Date().toISOString();
// Prevent circular prerequisites
if (_visited.has(flag.key)) {
return offResult(flag, 'error');
}
_visited.add(flag.key);
// 1. Check schedule
const scheduleActive = isScheduleActive(flag, now);
if (scheduleActive === false) {
return offResult(flag, 'schedule_inactive');
}
// 2. Check enabled
if (!flag.enabled) {
return offResult(flag, 'off');
}
// 3. Check archived
if (flag.archived) {
return offResult(flag, 'off');
}
// 4. Check platform / region / OS version
if (!matchesPlatform(flag, ctx) || !matchesRegion(flag, ctx) || !matchesOsVersion(flag, ctx)) {
return offResult(flag, 'off');
}
// 5. Check prerequisites
for (const prereq of flag.prerequisites) {
const prereqFlag = allFlags.find(f => f.key === prereq.flagKey);
if (!prereqFlag) {
return offResult(flag, 'prerequisite_failed');
}
const prereqResult = evaluateFlag({
flag: prereqFlag,
ctx,
allFlags,
segments,
now,
_visited: new Set(_visited),
});
if (prereqResult.reason === 'error') {
return offResult(flag, 'error');
}
if (prereqResult.variationKey !== prereq.variationKey) {
return offResult(flag, 'prerequisite_failed');
}
}
// 6. Individual user targeting
if (ctx.userId && flag.individualTargets[ctx.userId]) {
const targetVariation = flag.individualTargets[ctx.userId];
return variationResult(flag, targetVariation, 'individual_target');
}
// 7. Targeting rules (evaluated in order, first match wins)
for (const rule of flag.targetingRules) {
const allClausesMatch = rule.clauses.every(clause => evaluateClause(clause, ctx));
if (allClausesMatch) {
// Apply rule's rollout percentage
if (rule.rolloutPercentage >= 100) {
return variationResult(flag, rule.variationKey, 'rule_match');
}
if (rule.rolloutPercentage <= 0) continue;
if (
ctx.userId &&
hashUserFlag(ctx.userId, `${flag.key}:${rule.id}`) < rule.rolloutPercentage
) {
return variationResult(flag, rule.variationKey, 'rule_match');
}
}
}
// 8. Segment matching
if (flag.segments.length > 0 && ctx.userId) {
for (const segKey of flag.segments) {
const segment = segments.find(s => s.key === segKey);
if (segment && userMatchesSegment(segment, ctx)) {
return variationResult(flag, flag.defaultVariation, 'segment_match');
}
}
}
// 9. Percentage rollout
const effectivePercentage = getEffectivePercentage(flag, now);
if (effectivePercentage >= 100) {
return variationResult(flag, flag.defaultVariation, 'percentage_rollout');
}
if (effectivePercentage <= 0) {
return offResult(flag, 'off');
}
if (ctx.userId) {
const bucket = hashUserFlag(ctx.userId, flag.key);
if (bucket < effectivePercentage) {
return variationResult(flag, flag.defaultVariation, 'percentage_rollout');
}
return offResult(flag, 'off');
}
// 10. Default (no userId, full rollout)
return variationResult(flag, flag.defaultVariation, 'default');
}
/**
* Evaluate all flags for a given context. Returns a map of flagKey EvaluationResult.
*/
export function evaluateAllFlags(
flags: FeatureFlagDoc[],
ctx: EvaluationContext,
segments: SegmentDoc[],
now?: string
): Record<string, EvaluationResult> {
const results: Record<string, EvaluationResult> = {};
const activeFlags = flags.filter(f => !f.archived);
for (const flag of activeFlags) {
results[flag.key] = evaluateFlag({
flag,
ctx,
allFlags: activeFlags,
segments,
now,
});
}
return results;
}

View File

@ -1,9 +1,78 @@
/**
* Unit tests for feature flags module types + validation.
* Comprehensive tests for the production-grade feature flag system.
*
* Covers: schema validation, evaluator engine (targeting rules, segments,
* prerequisites, scheduling, gradual rollouts, individual targets),
* version comparison, and deterministic hashing.
*/
import { describe, it, expect } from 'vitest';
import { CreateFlagSchema, UpdateFlagSchema } from './types.js';
import {
CreateFlagSchema,
UpdateFlagSchema,
CreateSegmentSchema,
EvaluateSchema,
type FeatureFlagDoc,
type SegmentDoc,
} from './types.js';
import {
evaluateFlag,
evaluateAllFlags,
hashUserFlag,
compareVersions,
getEffectivePercentage,
} from './evaluator.js';
// ── Test helpers ────────────────────────────────────────────────────────────
function makeFlag(overrides: Partial<FeatureFlagDoc> = {}): FeatureFlagDoc {
return {
id: 'flag_test_feature',
productId: 'test',
key: 'feature',
flagType: 'boolean',
enabled: true,
archived: false,
description: 'Test flag',
tags: [],
variations: [
{ key: 'on', value: true, description: 'On' },
{ key: 'off', value: false, description: 'Off' },
],
defaultVariation: 'on',
offVariation: 'off',
platforms: [],
regions: [],
osVersions: [],
segments: [],
targetingRules: [],
individualTargets: {},
prerequisites: [],
percentage: 100,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
version: 1,
...overrides,
};
}
function makeSegment(overrides: Partial<SegmentDoc> = {}): SegmentDoc {
return {
id: 'seg_test_beta',
productId: 'test',
key: 'beta',
name: 'Beta Users',
description: 'Beta testers',
rules: [],
includedUsers: [],
excludedUsers: [],
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides,
};
}
// ── Schema validation ───────────────────────────────────────────────────────
describe('CreateFlagSchema', () => {
it('accepts valid flag with defaults', () => {
@ -11,124 +80,793 @@ describe('CreateFlagSchema', () => {
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.enabled).toBe(true);
expect(result.data.flagType).toBe('boolean');
expect(result.data.percentage).toBe(100);
expect(result.data.platforms).toEqual([]);
expect(result.data.regions).toEqual([]);
expect(result.data.osVersions).toEqual([]);
expect(result.data.segments).toEqual([]);
expect(result.data.targetingRules).toEqual([]);
expect(result.data.individualTargets).toEqual({});
expect(result.data.prerequisites).toEqual([]);
}
});
it('accepts full input', () => {
it('accepts dotted key names', () => {
const result = CreateFlagSchema.safeParse({ key: 'smartauth.oauth.google' });
expect(result.success).toBe(true);
});
it('accepts multi-variate string flag', () => {
const result = CreateFlagSchema.safeParse({
key: 'cta_color',
flagType: 'string',
variations: [
{ key: 'red', value: '#FF0000' },
{ key: 'blue', value: '#0000FF' },
{ key: 'green', value: '#00FF00' },
],
defaultVariation: 'red',
offVariation: 'blue',
});
expect(result.success).toBe(true);
});
it('accepts targeting rules', () => {
const result = CreateFlagSchema.safeParse({
key: 'new_ui',
enabled: false,
description: 'New dashboard UI',
platforms: ['web', 'ios'],
regions: ['us', 'eu'],
osVersions: [
{ platform: 'ios', minVersion: '17.0', maxVersion: '18.0' },
{ platform: 'android', minVersion: '14.0' },
targetingRules: [
{
id: 'rule_1',
clauses: [{ attribute: 'email', operator: 'ends_with', values: ['@bytelyst.com'] }],
variationKey: 'on',
rolloutPercentage: 100,
},
],
segments: ['beta'],
percentage: 50,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.regions).toEqual(['us', 'eu']);
expect(result.data.osVersions).toHaveLength(2);
}
});
it('accepts osVersions with only minVersion', () => {
const result = CreateFlagSchema.safeParse({
key: 'ios_only',
osVersions: [{ platform: 'ios', minVersion: '16.0' }],
});
expect(result.success).toBe(true);
});
it('accepts osVersions with only maxVersion', () => {
it('accepts schedule with gradual rollout', () => {
const result = CreateFlagSchema.safeParse({
key: 'legacy_only',
osVersions: [{ platform: 'android', maxVersion: '13.0' }],
key: 'gradual',
schedule: {
enableAt: '2026-04-01T00:00:00Z',
gradualRollout: {
startPercentage: 5,
endPercentage: 100,
startAt: '2026-04-01T00:00:00Z',
endAt: '2026-04-08T00:00:00Z',
},
},
});
expect(result.success).toBe(true);
});
it('rejects osVersions with empty platform', () => {
it('accepts prerequisites', () => {
const result = CreateFlagSchema.safeParse({
key: 'bad_os',
osVersions: [{ platform: '' }],
key: 'child_flag',
prerequisites: [{ flagKey: 'parent_flag', variationKey: 'on' }],
});
expect(result.success).toBe(false);
expect(result.success).toBe(true);
});
it('rejects key with spaces', () => {
const result = CreateFlagSchema.safeParse({ key: 'dark mode' });
expect(result.success).toBe(false);
expect(CreateFlagSchema.safeParse({ key: 'dark mode' }).success).toBe(false);
});
it('rejects key with uppercase', () => {
const result = CreateFlagSchema.safeParse({ key: 'DarkMode' });
expect(result.success).toBe(false);
expect(CreateFlagSchema.safeParse({ key: 'DarkMode' }).success).toBe(false);
});
it('rejects percentage > 100', () => {
const result = CreateFlagSchema.safeParse({ key: 'test', percentage: 150 });
expect(result.success).toBe(false);
expect(CreateFlagSchema.safeParse({ key: 'test', percentage: 150 }).success).toBe(false);
});
it('rejects percentage < 0', () => {
const result = CreateFlagSchema.safeParse({ key: 'test', percentage: -10 });
expect(CreateFlagSchema.safeParse({ key: 'test', percentage: -10 }).success).toBe(false);
});
it('rejects targeting rule with empty clauses', () => {
const result = CreateFlagSchema.safeParse({
key: 'bad',
targetingRules: [{ id: 'r1', clauses: [], variationKey: 'on' }],
});
expect(result.success).toBe(false);
});
});
describe('UpdateFlagSchema', () => {
it('accepts partial updates', () => {
const result = UpdateFlagSchema.safeParse({
enabled: false,
percentage: 25,
});
expect(result.success).toBe(true);
expect(UpdateFlagSchema.safeParse({ enabled: false, percentage: 25 }).success).toBe(true);
});
it('accepts empty object', () => {
const result = UpdateFlagSchema.safeParse({});
expect(result.success).toBe(true);
expect(UpdateFlagSchema.safeParse({}).success).toBe(true);
});
it('accepts platform list update', () => {
const result = UpdateFlagSchema.safeParse({
platforms: ['ios', 'android'],
});
expect(result.success).toBe(true);
it('accepts archived field', () => {
expect(UpdateFlagSchema.safeParse({ archived: true }).success).toBe(true);
});
it('accepts regions update', () => {
const result = UpdateFlagSchema.safeParse({
regions: ['us', 'apac'],
});
expect(result.success).toBe(true);
});
it('accepts osVersions update', () => {
const result = UpdateFlagSchema.safeParse({
osVersions: [{ platform: 'ios', minVersion: '17.0', maxVersion: '18.0' }],
});
expect(result.success).toBe(true);
it('accepts targeting rules update', () => {
expect(
UpdateFlagSchema.safeParse({
targetingRules: [
{
id: 'rule_1',
clauses: [{ attribute: 'platform', operator: 'eq', values: ['ios'] }],
variationKey: 'on',
},
],
}).success
).toBe(true);
});
});
describe('compareVersions', () => {
let compareVersions: (a: string, b: string) => number;
beforeAll(async () => {
const mod = await import('./routes.js');
compareVersions = mod.compareVersions;
describe('CreateSegmentSchema', () => {
it('accepts valid segment', () => {
const result = CreateSegmentSchema.safeParse({
key: 'beta_users',
name: 'Beta Users',
rules: [{ attribute: 'email', operator: 'ends_with', values: ['@bytelyst.com'] }],
includedUsers: ['user_1'],
});
expect(result.success).toBe(true);
});
it('rejects empty name', () => {
expect(CreateSegmentSchema.safeParse({ key: 'bad', name: '' }).success).toBe(false);
});
});
describe('EvaluateSchema', () => {
it('accepts full context', () => {
const result = EvaluateSchema.safeParse({
userId: 'user_1',
platform: 'ios',
region: 'us',
osVersion: '17.2',
appVersion: '2.1.0',
email: 'test@bytelyst.com',
custom: { plan: 'pro', age: 25 },
});
expect(result.success).toBe(true);
});
it('accepts empty context', () => {
expect(EvaluateSchema.safeParse({}).success).toBe(true);
});
});
// ── Evaluator engine ────────────────────────────────────────────────────────
describe('evaluateFlag', () => {
it('returns off when flag is disabled', () => {
const flag = makeFlag({ enabled: false });
const result = evaluateFlag({ flag, ctx: {}, allFlags: [flag], segments: [] });
expect(result.reason).toBe('off');
expect(result.value).toBe(false);
expect(result.variationKey).toBe('off');
});
it('returns off when flag is archived', () => {
const flag = makeFlag({ archived: true });
const result = evaluateFlag({ flag, ctx: {}, allFlags: [flag], segments: [] });
expect(result.reason).toBe('off');
});
it('returns default variation when fully enabled (100%)', () => {
const flag = makeFlag();
const result = evaluateFlag({
flag,
ctx: { userId: 'user_1' },
allFlags: [flag],
segments: [],
});
expect(result.reason).toBe('percentage_rollout');
expect(result.value).toBe(true);
expect(result.variationKey).toBe('on');
});
it('returns off when percentage is 0', () => {
const flag = makeFlag({ percentage: 0 });
const result = evaluateFlag({
flag,
ctx: { userId: 'user_1' },
allFlags: [flag],
segments: [],
});
expect(result.reason).toBe('off');
expect(result.value).toBe(false);
});
it('returns default with no userId at 100%', () => {
const flag = makeFlag();
const result = evaluateFlag({ flag, ctx: {}, allFlags: [flag], segments: [] });
// At 100%, the evaluator takes the percentage_rollout fast path
expect(result.reason).toBe('percentage_rollout');
expect(result.value).toBe(true);
});
// ── Platform / region / OS version filtering ──────────────────────────
it('filters by platform', () => {
const flag = makeFlag({ platforms: ['ios'] });
const on = evaluateFlag({
flag,
ctx: { userId: 'u1', platform: 'ios' },
allFlags: [flag],
segments: [],
});
expect(on.reason).not.toBe('off');
const off = evaluateFlag({
flag,
ctx: { userId: 'u1', platform: 'android' },
allFlags: [flag],
segments: [],
});
expect(off.reason).toBe('off');
});
it('filters by region', () => {
const flag = makeFlag({ regions: ['us', 'eu'] });
const on = evaluateFlag({
flag,
ctx: { userId: 'u1', region: 'us' },
allFlags: [flag],
segments: [],
});
expect(on.reason).not.toBe('off');
const off = evaluateFlag({
flag,
ctx: { userId: 'u1', region: 'apac' },
allFlags: [flag],
segments: [],
});
expect(off.reason).toBe('off');
});
it('filters by OS version range', () => {
const flag = makeFlag({
osVersions: [{ platform: 'ios', minVersion: '17.0', maxVersion: '18.0' }],
});
const on = evaluateFlag({
flag,
ctx: { userId: 'u1', platform: 'ios', osVersion: '17.2' },
allFlags: [flag],
segments: [],
});
expect(on.reason).not.toBe('off');
const off = evaluateFlag({
flag,
ctx: { userId: 'u1', platform: 'ios', osVersion: '16.4' },
allFlags: [flag],
segments: [],
});
expect(off.reason).toBe('off');
});
// ── Individual targeting ──────────────────────────────────────────────
it('matches individual user target', () => {
const flag = makeFlag({
individualTargets: { user_vip: 'on', user_blocked: 'off' },
});
const vip = evaluateFlag({ flag, ctx: { userId: 'user_vip' }, allFlags: [flag], segments: [] });
expect(vip.reason).toBe('individual_target');
expect(vip.value).toBe(true);
const blocked = evaluateFlag({
flag,
ctx: { userId: 'user_blocked' },
allFlags: [flag],
segments: [],
});
expect(blocked.reason).toBe('individual_target');
expect(blocked.value).toBe(false);
});
// ── Targeting rules ───────────────────────────────────────────────────
it('evaluates targeting rule with eq operator', () => {
const flag = makeFlag({
percentage: 0,
targetingRules: [
{
id: 'r1',
clauses: [{ attribute: 'platform', operator: 'eq', values: ['ios'] }],
variationKey: 'on',
rolloutPercentage: 100,
},
],
});
const ios = evaluateFlag({
flag,
ctx: { userId: 'u1', platform: 'ios' },
allFlags: [flag],
segments: [],
});
expect(ios.reason).toBe('rule_match');
expect(ios.value).toBe(true);
const android = evaluateFlag({
flag,
ctx: { userId: 'u1', platform: 'android' },
allFlags: [flag],
segments: [],
});
expect(android.reason).toBe('off');
});
it('evaluates targeting rule with ends_with operator', () => {
const flag = makeFlag({
percentage: 0,
targetingRules: [
{
id: 'r1',
clauses: [{ attribute: 'email', operator: 'ends_with', values: ['@bytelyst.com'] }],
variationKey: 'on',
rolloutPercentage: 100,
},
],
});
const match = evaluateFlag({
flag,
ctx: { userId: 'u1', email: 'dev@bytelyst.com' },
allFlags: [flag],
segments: [],
});
expect(match.reason).toBe('rule_match');
const noMatch = evaluateFlag({
flag,
ctx: { userId: 'u1', email: 'user@gmail.com' },
allFlags: [flag],
segments: [],
});
expect(noMatch.reason).toBe('off');
});
it('evaluates targeting rule with in operator', () => {
const flag = makeFlag({
percentage: 0,
targetingRules: [
{
id: 'r1',
clauses: [{ attribute: 'region', operator: 'in', values: ['us', 'eu'] }],
variationKey: 'on',
rolloutPercentage: 100,
},
],
});
const us = evaluateFlag({
flag,
ctx: { userId: 'u1', region: 'us' },
allFlags: [flag],
segments: [],
});
expect(us.reason).toBe('rule_match');
const apac = evaluateFlag({
flag,
ctx: { userId: 'u1', region: 'apac' },
allFlags: [flag],
segments: [],
});
expect(apac.reason).toBe('off');
});
it('evaluates targeting rule with regex operator', () => {
const flag = makeFlag({
percentage: 0,
targetingRules: [
{
id: 'r1',
clauses: [{ attribute: 'email', operator: 'regex', values: ['^admin@'] }],
variationKey: 'on',
rolloutPercentage: 100,
},
],
});
const match = evaluateFlag({
flag,
ctx: { userId: 'u1', email: 'admin@bytelyst.com' },
allFlags: [flag],
segments: [],
});
expect(match.reason).toBe('rule_match');
const noMatch = evaluateFlag({
flag,
ctx: { userId: 'u1', email: 'user@bytelyst.com' },
allFlags: [flag],
segments: [],
});
expect(noMatch.reason).toBe('off');
});
it('evaluates targeting rule with semver_gte operator', () => {
const flag = makeFlag({
percentage: 0,
targetingRules: [
{
id: 'r1',
clauses: [{ attribute: 'appVersion', operator: 'semver_gte', values: ['2.0.0'] }],
variationKey: 'on',
rolloutPercentage: 100,
},
],
});
const v2 = evaluateFlag({
flag,
ctx: { userId: 'u1', appVersion: '2.1.0' },
allFlags: [flag],
segments: [],
});
expect(v2.reason).toBe('rule_match');
const v1 = evaluateFlag({
flag,
ctx: { userId: 'u1', appVersion: '1.9.0' },
allFlags: [flag],
segments: [],
});
expect(v1.reason).toBe('off');
});
it('evaluates custom attribute in targeting rule', () => {
const flag = makeFlag({
percentage: 0,
targetingRules: [
{
id: 'r1',
clauses: [{ attribute: 'plan', operator: 'eq', values: ['pro'] }],
variationKey: 'on',
rolloutPercentage: 100,
},
],
});
const pro = evaluateFlag({
flag,
ctx: { userId: 'u1', custom: { plan: 'pro' } },
allFlags: [flag],
segments: [],
});
expect(pro.reason).toBe('rule_match');
const free = evaluateFlag({
flag,
ctx: { userId: 'u1', custom: { plan: 'free' } },
allFlags: [flag],
segments: [],
});
expect(free.reason).toBe('off');
});
it('requires all clauses to match in a rule (AND logic)', () => {
const flag = makeFlag({
percentage: 0,
targetingRules: [
{
id: 'r1',
clauses: [
{ attribute: 'platform', operator: 'eq', values: ['ios'] },
{ attribute: 'region', operator: 'eq', values: ['us'] },
],
variationKey: 'on',
rolloutPercentage: 100,
},
],
});
const both = evaluateFlag({
flag,
ctx: { userId: 'u1', platform: 'ios', region: 'us' },
allFlags: [flag],
segments: [],
});
expect(both.reason).toBe('rule_match');
const onlyPlatform = evaluateFlag({
flag,
ctx: { userId: 'u1', platform: 'ios', region: 'eu' },
allFlags: [flag],
segments: [],
});
expect(onlyPlatform.reason).toBe('off');
});
it('first matching rule wins', () => {
const flag = makeFlag({
key: 'multi_rule',
percentage: 0,
variations: [
{ key: 'on', value: true },
{ key: 'off', value: false },
{ key: 'beta', value: 'beta-ui' },
],
targetingRules: [
{
id: 'r1',
clauses: [{ attribute: 'email', operator: 'ends_with', values: ['@bytelyst.com'] }],
variationKey: 'beta',
rolloutPercentage: 100,
},
{
id: 'r2',
clauses: [{ attribute: 'platform', operator: 'eq', values: ['ios'] }],
variationKey: 'on',
rolloutPercentage: 100,
},
],
});
// Matches both rules — first (beta) should win
const result = evaluateFlag({
flag,
ctx: { userId: 'u1', email: 'dev@bytelyst.com', platform: 'ios' },
allFlags: [flag],
segments: [],
});
expect(result.variationKey).toBe('beta');
expect(result.value).toBe('beta-ui');
});
// ── Segments ──────────────────────────────────────────────────────────
it('matches user in segment includedUsers', () => {
const flag = makeFlag({ percentage: 0, segments: ['beta'] });
const segment = makeSegment({ includedUsers: ['user_1'] });
const match = evaluateFlag({
flag,
ctx: { userId: 'user_1' },
allFlags: [flag],
segments: [segment],
});
expect(match.reason).toBe('segment_match');
expect(match.value).toBe(true);
});
it('excludes user in segment excludedUsers', () => {
const flag = makeFlag({ percentage: 0, segments: ['beta'] });
const segment = makeSegment({ includedUsers: ['user_1'], excludedUsers: ['user_1'] });
const result = evaluateFlag({
flag,
ctx: { userId: 'user_1' },
allFlags: [flag],
segments: [segment],
});
expect(result.reason).toBe('off');
});
it('matches segment via rules', () => {
const flag = makeFlag({ percentage: 0, segments: ['internal'] });
const segment = makeSegment({
key: 'internal',
rules: [{ attribute: 'email', operator: 'ends_with', values: ['@bytelyst.com'] }],
});
const match = evaluateFlag({
flag,
ctx: { userId: 'u1', email: 'dev@bytelyst.com' },
allFlags: [flag],
segments: [segment],
});
expect(match.reason).toBe('segment_match');
});
// ── Prerequisites ─────────────────────────────────────────────────────
it('fails when prerequisite flag is off', () => {
const parent = makeFlag({ key: 'parent', enabled: false });
const child = makeFlag({
key: 'child',
prerequisites: [{ flagKey: 'parent', variationKey: 'on' }],
});
const result = evaluateFlag({
flag: child,
ctx: { userId: 'u1' },
allFlags: [parent, child],
segments: [],
});
expect(result.reason).toBe('prerequisite_failed');
});
it('succeeds when prerequisite flag is on with correct variation', () => {
const parent = makeFlag({ key: 'parent', enabled: true });
const child = makeFlag({
key: 'child',
prerequisites: [{ flagKey: 'parent', variationKey: 'on' }],
});
const result = evaluateFlag({
flag: child,
ctx: { userId: 'u1' },
allFlags: [parent, child],
segments: [],
});
expect(result.reason).not.toBe('prerequisite_failed');
});
it('handles circular prerequisites gracefully', () => {
const a = makeFlag({ key: 'a', prerequisites: [{ flagKey: 'b', variationKey: 'on' }] });
const b = makeFlag({ key: 'b', prerequisites: [{ flagKey: 'a', variationKey: 'on' }] });
const result = evaluateFlag({ flag: a, ctx: { userId: 'u1' }, allFlags: [a, b], segments: [] });
expect(result.reason).toBe('error');
});
it('fails when prerequisite flag does not exist', () => {
const child = makeFlag({
key: 'child',
prerequisites: [{ flagKey: 'nonexistent', variationKey: 'on' }],
});
const result = evaluateFlag({
flag: child,
ctx: { userId: 'u1' },
allFlags: [child],
segments: [],
});
expect(result.reason).toBe('prerequisite_failed');
});
// ── Scheduling ────────────────────────────────────────────────────────
it('returns schedule_inactive before enableAt', () => {
const flag = makeFlag({
schedule: { enableAt: '2026-06-01T00:00:00Z' },
});
const result = evaluateFlag({
flag,
ctx: { userId: 'u1' },
allFlags: [flag],
segments: [],
now: '2026-05-15T00:00:00Z',
});
expect(result.reason).toBe('schedule_inactive');
});
it('returns active after enableAt', () => {
const flag = makeFlag({
schedule: { enableAt: '2026-06-01T00:00:00Z' },
});
const result = evaluateFlag({
flag,
ctx: { userId: 'u1' },
allFlags: [flag],
segments: [],
now: '2026-06-02T00:00:00Z',
});
expect(result.reason).not.toBe('schedule_inactive');
});
it('returns schedule_inactive after disableAt', () => {
const flag = makeFlag({
schedule: { disableAt: '2026-06-01T00:00:00Z' },
});
const result = evaluateFlag({
flag,
ctx: { userId: 'u1' },
allFlags: [flag],
segments: [],
now: '2026-06-02T00:00:00Z',
});
expect(result.reason).toBe('schedule_inactive');
});
});
// ── Gradual rollout ─────────────────────────────────────────────────────────
describe('getEffectivePercentage', () => {
it('returns flag percentage when no gradual rollout', () => {
const flag = makeFlag({ percentage: 50 });
expect(getEffectivePercentage(flag, '2026-01-01T00:00:00Z')).toBe(50);
});
it('returns startPercentage before rollout starts', () => {
const flag = makeFlag({
schedule: {
gradualRollout: {
startPercentage: 5,
endPercentage: 100,
startAt: '2026-04-01T00:00:00Z',
endAt: '2026-04-08T00:00:00Z',
},
},
});
expect(getEffectivePercentage(flag, '2026-03-31T00:00:00Z')).toBe(5);
});
it('returns endPercentage after rollout ends', () => {
const flag = makeFlag({
schedule: {
gradualRollout: {
startPercentage: 5,
endPercentage: 100,
startAt: '2026-04-01T00:00:00Z',
endAt: '2026-04-08T00:00:00Z',
},
},
});
expect(getEffectivePercentage(flag, '2026-04-09T00:00:00Z')).toBe(100);
});
it('interpolates at midpoint', () => {
const flag = makeFlag({
schedule: {
gradualRollout: {
startPercentage: 0,
endPercentage: 100,
startAt: '2026-04-01T00:00:00Z',
endAt: '2026-04-11T00:00:00Z', // 10 days
},
},
});
const pct = getEffectivePercentage(flag, '2026-04-06T00:00:00Z'); // 5 days in
expect(pct).toBe(50);
});
});
// ── Multi-variate evaluation ────────────────────────────────────────────────
describe('multi-variate flags', () => {
it('returns string variation value', () => {
const flag = makeFlag({
key: 'theme',
flagType: 'string',
variations: [
{ key: 'dark', value: 'dark-theme' },
{ key: 'light', value: 'light-theme' },
],
defaultVariation: 'dark',
offVariation: 'light',
});
const result = evaluateFlag({ flag, ctx: { userId: 'u1' }, allFlags: [flag], segments: [] });
expect(result.value).toBe('dark-theme');
expect(result.variationKey).toBe('dark');
});
it('returns JSON variation via targeting rule', () => {
const flag = makeFlag({
key: 'config',
flagType: 'json',
percentage: 0,
variations: [
{ key: 'v1', value: { maxItems: 10 } },
{ key: 'v2', value: { maxItems: 50 } },
],
defaultVariation: 'v1',
offVariation: 'v1',
targetingRules: [
{
id: 'r1',
clauses: [{ attribute: 'plan', operator: 'eq', values: ['pro'] }],
variationKey: 'v2',
rolloutPercentage: 100,
},
],
});
const pro = evaluateFlag({
flag,
ctx: { userId: 'u1', custom: { plan: 'pro' } },
allFlags: [flag],
segments: [],
});
expect(pro.value).toEqual({ maxItems: 50 });
expect(pro.variationKey).toBe('v2');
});
});
// ── evaluateAllFlags ────────────────────────────────────────────────────────
describe('evaluateAllFlags', () => {
it('evaluates all non-archived flags', () => {
const flags = [
makeFlag({ key: 'a', enabled: true }),
makeFlag({ key: 'b', enabled: false }),
makeFlag({ key: 'c', archived: true }),
];
const results = evaluateAllFlags(flags, { userId: 'u1' }, []);
expect(Object.keys(results)).toEqual(['a', 'b']);
expect(results['a'].reason).not.toBe('off');
expect(results['b'].reason).toBe('off');
});
});
// ── Version comparison ──────────────────────────────────────────────────────
describe('compareVersions', () => {
it('equal versions return 0', () => {
expect(compareVersions('17.2.1', '17.2.1')).toBe(0);
});
@ -155,34 +893,20 @@ describe('compareVersions', () => {
});
});
describe('hashUserFlag (deterministic A/B assignment)', () => {
// Import the hash function from routes
let hashUserFlag: (userId: string, flagKey: string) => number;
beforeAll(async () => {
const mod = await import('./routes.js');
hashUserFlag = mod.hashUserFlag;
});
// ── Deterministic hashing ───────────────────────────────────────────────────
describe('hashUserFlag', () => {
it('returns the same bucket for the same user+flag', () => {
const a = hashUserFlag('user_123', 'dark_mode');
const b = hashUserFlag('user_123', 'dark_mode');
const c = hashUserFlag('user_123', 'dark_mode');
expect(a).toBe(b);
expect(b).toBe(c);
});
it('returns different buckets for different users', () => {
const a = hashUserFlag('user_1', 'feature_x');
const b = hashUserFlag('user_2', 'feature_x');
// Statistically they should differ (not guaranteed, but extremely likely)
// We test multiple pairs to be safe
expect(a).not.toBe(b);
const buckets = new Set<number>();
for (let i = 0; i < 20; i++) {
buckets.add(hashUserFlag(`user_${i}`, 'feature_x'));
}
// At least 5 distinct buckets out of 20 users
expect(buckets.size).toBeGreaterThanOrEqual(5);
});
@ -201,7 +925,6 @@ describe('hashUserFlag (deterministic A/B assignment)', () => {
});
it('produces roughly uniform distribution', () => {
// Hash 1000 users, check that no bucket range gets >70% or <30%
let below50 = 0;
const total = 1000;
for (let i = 0; i < total; i++) {

View File

@ -1,29 +1,36 @@
/**
* Feature flags repository cloud-agnostic via @bytelyst/datastore.
*
* Three collections:
* - feature_flags flag documents (partition: /productId)
* - flag_segments user segments (partition: /productId)
* - flag_audit_log change audit trail (partition: /productId)
*/
import { getCollection } from '../../lib/datastore.js';
import type { FeatureFlagDoc } from './types.js';
import type { FeatureFlagDoc, SegmentDoc, FlagAuditDoc } from './types.js';
function collection() {
// ── Flags ───────────────────────────────────────────────────────────────────
function flagCollection() {
return getCollection<FeatureFlagDoc>('feature_flags', '/productId');
}
export async function list(productId: string): Promise<FeatureFlagDoc[]> {
return collection().findMany({
return flagCollection().findMany({
filter: { productId },
sort: { key: 1 },
});
}
export async function getByKey(key: string, productId: string): Promise<FeatureFlagDoc | null> {
return collection().findOne({
return flagCollection().findOne({
filter: { productId, key },
});
}
export async function create(doc: FeatureFlagDoc): Promise<FeatureFlagDoc> {
return collection().create(doc);
return flagCollection().create(doc);
}
export async function update(
@ -31,7 +38,10 @@ export async function update(
updates: Partial<FeatureFlagDoc>
): Promise<FeatureFlagDoc | null> {
try {
return await collection().update(id, id, { ...updates, updatedAt: new Date().toISOString() });
return await flagCollection().update(id, id, {
...updates,
updatedAt: new Date().toISOString(),
});
} catch {
return null;
}
@ -39,9 +49,79 @@ export async function update(
export async function remove(id: string): Promise<boolean> {
try {
await collection().delete(id, id);
await flagCollection().delete(id, id);
return true;
} catch {
return false;
}
}
// ── Segments ────────────────────────────────────────────────────────────────
function segmentCollection() {
return getCollection<SegmentDoc>('flag_segments', '/productId');
}
export async function listSegments(productId: string): Promise<SegmentDoc[]> {
return segmentCollection().findMany({
filter: { productId },
sort: { key: 1 },
});
}
export async function getSegmentByKey(key: string, productId: string): Promise<SegmentDoc | null> {
return segmentCollection().findOne({
filter: { productId, key },
});
}
export async function createSegment(doc: SegmentDoc): Promise<SegmentDoc> {
return segmentCollection().create(doc);
}
export async function updateSegment(
id: string,
updates: Partial<SegmentDoc>
): Promise<SegmentDoc | null> {
try {
return await segmentCollection().update(id, id, {
...updates,
updatedAt: new Date().toISOString(),
});
} catch {
return null;
}
}
export async function removeSegment(id: string): Promise<boolean> {
try {
await segmentCollection().delete(id, id);
return true;
} catch {
return false;
}
}
// ── Audit Log ───────────────────────────────────────────────────────────────
function auditCollection() {
return getCollection<FlagAuditDoc>('flag_audit_log', '/productId');
}
export async function createAuditEntry(doc: FlagAuditDoc): Promise<FlagAuditDoc> {
return auditCollection().create(doc);
}
export async function listAuditLog(
productId: string,
flagKey?: string,
limit = 50
): Promise<FlagAuditDoc[]> {
const filter = flagKey ? { productId, flagKey } : { productId };
const all = await auditCollection().findMany({
filter,
sort: { timestamp: -1 },
});
return all.slice(0, limit);
}

View File

@ -1,112 +1,216 @@
/**
* Feature flags REST endpoints.
* Feature flags REST endpoints production-grade feature management.
*
* GET /flags list all flags (admin)
* GET /flags/poll polling endpoint for clients (returns enabled flags)
* GET /flags/:key get single flag
* POST /flags create flag
* PUT /flags/:key update flag
* DELETE /flags/:key delete flag
* POST /flags/kill kill switch (disable all flags matching criteria)
* Flag CRUD
* GET /flags list all flags (admin)
* GET /flags/poll legacy boolean polling (backward-compat)
* POST /flags/evaluate full evaluation with context (multi-variate)
* GET /flags/:key get single flag
* POST /flags create flag
* PUT /flags/:key update flag
* DELETE /flags/:key delete flag
* POST /flags/:key/toggle quick toggle enabled state
* POST /flags/:key/archive archive/unarchive flag
* POST /flags/kill kill switch (disable all matching flags)
*
* Segments
* GET /flags/segments list segments
* GET /flags/segments/:key get segment
* POST /flags/segments create segment
* PUT /flags/segments/:key update segment
* DELETE /flags/segments/:key delete segment
*
* Audit
* GET /flags/audit list audit entries
* GET /flags/audit/:flagKey audit for specific flag
*
* SSE
* GET /flags/stream real-time flag change stream
*/
import type { FastifyInstance } from 'fastify';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { getRequestProductId } from '../../lib/request-context.js';
import { BadRequestError, NotFoundError } from '../../lib/errors.js';
import { bus } from '../../lib/event-bus.js';
import * as repo from './repository.js';
import { CreateFlagSchema, UpdateFlagSchema, type FeatureFlagDoc } from './types.js';
/**
* FNV-1a hash deterministic 32-bit hash for user+flag assignment.
* Same (userId, flagKey) pair always produces the same 0-99 bucket.
*/
function hashUserFlag(userId: string, flagKey: string): number {
const input = `${userId}:${flagKey}`;
let hash = 0x811c9dc5; // FNV offset basis
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 0x01000193); // FNV prime
}
return (hash >>> 0) % 100; // 0-99 bucket
}
/**
* Compare two dot-separated version strings (e.g. "17.2.1" vs "18.0").
* Returns -1 if a < b, 0 if equal, 1 if a > b.
*/
function compareVersions(a: string, b: string): number {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const na = pa[i] ?? 0;
const nb = pb[i] ?? 0;
if (na < nb) return -1;
if (na > nb) return 1;
}
return 0;
}
import {
CreateFlagSchema,
UpdateFlagSchema,
CreateSegmentSchema,
UpdateSegmentSchema,
EvaluateSchema,
type FeatureFlagDoc,
type FlagAuditDoc,
} from './types.js';
import { evaluateAllFlags, hashUserFlag, compareVersions } from './evaluator.js';
// Re-export for backward compatibility with existing tests
export { hashUserFlag, compareVersions };
// ── Helpers ─────────────────────────────────────────────────────────────────
function getActor(req: FastifyRequest): string {
return req.jwtPayload?.sub ?? 'system';
}
function diffChanges(before: Partial<FeatureFlagDoc>, after: Partial<FeatureFlagDoc>): string[] {
const changes: string[] = [];
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
for (const k of keys) {
const key = k as keyof FeatureFlagDoc;
if (key === 'updatedAt' || key === 'updatedBy' || key === 'version') continue;
const bVal = JSON.stringify(before[key]);
const aVal = JSON.stringify(after[key]);
if (bVal !== aVal) changes.push(key);
}
return changes;
}
async function recordAudit(
productId: string,
flagKey: string,
action: FlagAuditDoc['action'],
actor: string,
before?: Partial<FeatureFlagDoc>,
after?: Partial<FeatureFlagDoc>
): Promise<void> {
const changes = before && after ? diffChanges(before, after) : undefined;
const doc: FlagAuditDoc = {
id: `faudit_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
productId,
flagKey,
action,
actor,
before,
after,
changes,
timestamp: new Date().toISOString(),
};
await repo.createAuditEntry(doc);
}
function buildDefaultVariations(flagType: string) {
switch (flagType) {
case 'boolean':
return [
{ key: 'on', value: true, description: 'Flag is on' },
{ key: 'off', value: false, description: 'Flag is off' },
];
case 'string':
return [{ key: 'default', value: '', description: 'Default string value' }];
case 'number':
return [{ key: 'default', value: 0, description: 'Default number value' }];
case 'json':
return [{ key: 'default', value: {}, description: 'Default JSON value' }];
default:
return [
{ key: 'on', value: true },
{ key: 'off', value: false },
];
}
}
// ── SSE connected clients ───────────────────────────────────────────────────
interface SseClient {
productId: string;
write: (data: string) => void;
close: () => void;
}
const sseClients: Set<SseClient> = new Set();
function broadcastFlagChange(productId: string, flagKey: string, action: string): void {
const event = JSON.stringify({
type: 'flag_change',
flagKey,
action,
timestamp: new Date().toISOString(),
});
for (const client of sseClients) {
if (client.productId === productId) {
try {
client.write(`data: ${event}\n\n`);
} catch {
sseClients.delete(client);
}
}
}
}
// ── Routes ──────────────────────────────────────────────────────────────────
export async function flagRoutes(app: FastifyInstance) {
// List all flags
// ── List all flags ──────────────────────────────────────────────────────
app.get('/flags', async req => {
const productId = getRequestProductId(req);
return { flags: await repo.list(productId) };
const { archived, tag } = req.query as { archived?: string; tag?: string };
let flags = await repo.list(productId);
if (archived !== 'true') {
flags = flags.filter(f => !f.archived);
}
if (tag) {
flags = flags.filter(f => f.tags?.includes(tag));
}
return { flags, total: flags.length };
});
// Polling endpoint for clients
// ?userId=xxx — deterministic hash ensures same user always gets same flag assignment
// ?platform=xxx — filter flags by platform (e.g. ios, android, web, desktop)
// ?region=xxx — filter flags by region (e.g. us, eu, apac)
// ?osVersion=xxx — filter flags by OS version (e.g. 17.2.1)
// ── Legacy boolean polling (backward-compat) ───────────────────────────
app.get('/flags/poll', async req => {
const productId = getRequestProductId(req);
const { platform, userId, region, osVersion } = req.query as {
const query = req.query as {
platform?: string;
userId?: string;
region?: string;
osVersion?: string;
appVersion?: string;
};
const all = await repo.list(productId);
const active = all.filter(f => {
if (!f.enabled) return false;
// Platform targeting: flag specifies which platforms it applies to
if (f.platforms.length > 0 && platform && !f.platforms.includes(platform)) return false;
// Region targeting: flag specifies which regions it applies to
if (f.regions?.length > 0 && region && !f.regions.includes(region)) return false;
// OS version targeting: flag specifies version ranges per platform
if (f.osVersions?.length > 0 && platform && osVersion) {
const rule = f.osVersions.find(r => r.platform === platform);
if (rule) {
if (rule.minVersion && compareVersions(osVersion, rule.minVersion) < 0) return false;
if (rule.maxVersion && compareVersions(osVersion, rule.maxVersion) > 0) return false;
} else {
// osVersions defined but no rule for this platform — skip this flag
return false;
}
}
return true;
});
const allFlags = await repo.list(productId);
const segments = await repo.listSegments(productId);
const results = evaluateAllFlags(
allFlags,
{
userId: query.userId,
platform: query.platform,
region: query.region,
osVersion: query.osVersion,
appVersion: query.appVersion,
},
segments
);
// Legacy format: { flags: { key: boolean }, productId }
const flags: Record<string, boolean> = {};
for (const f of active) {
if (f.percentage >= 100) {
flags[f.key] = true;
} else if (f.percentage <= 0) {
flags[f.key] = false;
} else if (userId) {
// Deterministic: same user+flag always gets the same result
flags[f.key] = hashUserFlag(userId, f.key) < f.percentage;
} else {
// Fallback for anonymous requests (no userId)
flags[f.key] = Math.random() * 100 < f.percentage;
}
for (const [key, result] of Object.entries(results)) {
flags[key] =
result.reason !== 'off' &&
result.reason !== 'prerequisite_failed' &&
result.reason !== 'schedule_inactive' &&
result.reason !== 'error';
}
return { flags, productId };
});
// Get flag
// ── Full evaluation (multi-variate) ────────────────────────────────────
app.post('/flags/evaluate', async req => {
const productId = getRequestProductId(req);
const parsed = EvaluateSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const allFlags = await repo.list(productId);
const segments = await repo.listSegments(productId);
const results = evaluateAllFlags(allFlags, parsed.data, segments);
return { evaluations: results, productId };
});
// ── Get single flag ────────────────────────────────────────────────────
app.get('/flags/:key', async req => {
const { key } = req.params as { key: string };
const productId = getRequestProductId(req);
@ -115,7 +219,7 @@ export async function flagRoutes(app: FastifyInstance) {
return flag;
});
// Create flag
// ── Create flag ────────────────────────────────────────────────────────
app.post('/flags', async (req, reply) => {
const productId = getRequestProductId(req);
const parsed = CreateFlagSchema.safeParse(req.body);
@ -126,20 +230,56 @@ export async function flagRoutes(app: FastifyInstance) {
const existing = await repo.getByKey(input.key, productId);
if (existing) throw new BadRequestError(`Flag "${input.key}" already exists`);
const variations = input.variations ?? buildDefaultVariations(input.flagType);
const defaultVariation = input.defaultVariation ?? variations[0]?.key ?? 'on';
const offVariation =
input.offVariation ?? (input.flagType === 'boolean' ? 'off' : (variations[0]?.key ?? 'off'));
const now = new Date().toISOString();
const actor = getActor(req);
const doc: FeatureFlagDoc = {
id: `flag_${productId}_${input.key}`,
productId,
...input,
key: input.key,
flagType: input.flagType,
enabled: input.enabled,
archived: false,
description: input.description,
tags: input.tags,
variations,
defaultVariation,
offVariation,
platforms: input.platforms,
regions: input.regions,
osVersions: input.osVersions,
segments: input.segments,
targetingRules: input.targetingRules,
individualTargets: input.individualTargets,
prerequisites: input.prerequisites,
percentage: input.percentage,
schedule: input.schedule,
createdAt: now,
updatedAt: now,
createdBy: actor,
updatedBy: actor,
version: 1,
};
const created = await repo.create(doc);
await recordAudit(productId, input.key, 'created', actor, undefined, created);
broadcastFlagChange(productId, input.key, 'created');
try {
bus.emit('flag.created', { productId, flagKey: input.key, actor });
} catch {
/* best-effort */
}
reply.code(201);
return created;
});
// Update flag
// ── Update flag ────────────────────────────────────────────────────────
app.put('/flags/:key', async req => {
const { key } = req.params as { key: string };
const productId = getRequestProductId(req);
@ -150,37 +290,262 @@ export async function flagRoutes(app: FastifyInstance) {
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const updated = await repo.update(flag.id, parsed.data);
const actor = getActor(req);
const before = { ...flag };
const updates = { ...parsed.data, updatedBy: actor, version: flag.version + 1 };
const updated = await repo.update(flag.id, updates);
if (!updated) throw new NotFoundError('Flag update failed');
await recordAudit(productId, key, 'updated', actor, before, updated);
broadcastFlagChange(productId, key, 'updated');
try {
bus.emit('flag.updated', {
productId,
flagKey: key,
actor,
changes: diffChanges(before, updated),
});
} catch {
/* best-effort */
}
return updated;
});
// Delete flag
// ── Toggle flag ────────────────────────────────────────────────────────
app.post('/flags/:key/toggle', async req => {
const { key } = req.params as { key: string };
const productId = getRequestProductId(req);
const flag = await repo.getByKey(key, productId);
if (!flag) throw new NotFoundError('Flag not found');
const actor = getActor(req);
const before = { ...flag };
const updated = await repo.update(flag.id, {
enabled: !flag.enabled,
updatedBy: actor,
version: flag.version + 1,
});
if (!updated) throw new NotFoundError('Flag toggle failed');
await recordAudit(productId, key, 'toggled', actor, before, updated);
broadcastFlagChange(productId, key, 'toggled');
try {
bus.emit('flag.toggled', {
productId,
flagId: flag.id,
flagKey: key,
enabled: !flag.enabled,
actor,
});
} catch {
/* best-effort */
}
return updated;
});
// ── Archive/unarchive flag ─────────────────────────────────────────────
app.post('/flags/:key/archive', async req => {
const { key } = req.params as { key: string };
const { archived } = (req.body as { archived?: boolean }) ?? {};
const productId = getRequestProductId(req);
const flag = await repo.getByKey(key, productId);
if (!flag) throw new NotFoundError('Flag not found');
const newArchived = archived ?? !flag.archived;
const actor = getActor(req);
const before = { ...flag };
const updated = await repo.update(flag.id, {
archived: newArchived,
enabled: newArchived ? false : flag.enabled,
updatedBy: actor,
version: flag.version + 1,
});
if (!updated) throw new NotFoundError('Flag archive failed');
await recordAudit(productId, key, 'archived', actor, before, updated);
broadcastFlagChange(productId, key, newArchived ? 'archived' : 'unarchived');
return updated;
});
// ── Delete flag ────────────────────────────────────────────────────────
app.delete('/flags/:key', async req => {
const { key } = req.params as { key: string };
const productId = getRequestProductId(req);
const flag = await repo.getByKey(key, productId);
if (!flag) throw new NotFoundError('Flag not found');
const actor = getActor(req);
await repo.remove(flag.id);
await recordAudit(productId, key, 'deleted', actor, flag, undefined);
broadcastFlagChange(productId, key, 'deleted');
try {
bus.emit('flag.deleted', { productId, flagKey: key, actor });
} catch {
/* best-effort */
}
return { success: true };
});
// Kill switch — disable all flags matching optional platform filter
// ── Kill switch ────────────────────────────────────────────────────────
app.post('/flags/kill', async req => {
const { platform, keys } = req.body as { platform?: string; keys?: string[] };
const { platform, keys, tags } = req.body as {
platform?: string;
keys?: string[];
tags?: string[];
};
const productId = getRequestProductId(req);
const actor = getActor(req);
const all = await repo.list(productId);
const toDisable = all.filter(f => {
if (!f.enabled) return false;
if (keys && keys.length > 0 && !keys.includes(f.key)) return false;
if (platform && f.platforms.length > 0 && !f.platforms.includes(platform)) return false;
return f.enabled;
if (tags && tags.length > 0 && !tags.some(t => f.tags?.includes(t))) return false;
return true;
});
const disabled: string[] = [];
for (const f of toDisable) {
await repo.update(f.id, { enabled: false });
const before = { ...f };
await repo.update(f.id, { enabled: false, updatedBy: actor, version: f.version + 1 });
await recordAudit(productId, f.key, 'kill_switch', actor, before, { ...f, enabled: false });
broadcastFlagChange(productId, f.key, 'kill_switch');
disabled.push(f.key);
}
try {
bus.emit('flag.kill_switch', { productId, disabled, actor });
} catch {
/* best-effort */
}
return { disabled, count: disabled.length };
});
// ── Segments CRUD ─────────────────────────────────────────────────────
app.get('/flags/segments', async req => {
const productId = getRequestProductId(req);
const segments = await repo.listSegments(productId);
return { segments, total: segments.length };
});
app.get('/flags/segments/:key', async req => {
const { key } = req.params as { key: string };
const productId = getRequestProductId(req);
const segment = await repo.getSegmentByKey(key, productId);
if (!segment) throw new NotFoundError('Segment not found');
return segment;
});
app.post('/flags/segments', async (req, reply) => {
const productId = getRequestProductId(req);
const parsed = CreateSegmentSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const input = parsed.data;
const existing = await repo.getSegmentByKey(input.key, productId);
if (existing) throw new BadRequestError(`Segment "${input.key}" already exists`);
const now = new Date().toISOString();
const doc = {
id: `seg_${productId}_${input.key}`,
productId,
...input,
createdAt: now,
updatedAt: now,
};
const created = await repo.createSegment(doc);
reply.code(201);
return created;
});
app.put('/flags/segments/:key', async req => {
const { key } = req.params as { key: string };
const productId = getRequestProductId(req);
const segment = await repo.getSegmentByKey(key, productId);
if (!segment) throw new NotFoundError('Segment not found');
const parsed = UpdateSegmentSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const updated = await repo.updateSegment(segment.id, parsed.data);
if (!updated) throw new NotFoundError('Segment update failed');
return updated;
});
app.delete('/flags/segments/:key', async req => {
const { key } = req.params as { key: string };
const productId = getRequestProductId(req);
const segment = await repo.getSegmentByKey(key, productId);
if (!segment) throw new NotFoundError('Segment not found');
await repo.removeSegment(segment.id);
return { success: true };
});
// ── Audit log ─────────────────────────────────────────────────────────
app.get('/flags/audit', async req => {
const productId = getRequestProductId(req);
const { limit } = req.query as { limit?: string };
const entries = await repo.listAuditLog(productId, undefined, limit ? parseInt(limit, 10) : 50);
return { entries, total: entries.length };
});
app.get('/flags/audit/:flagKey', async req => {
const { flagKey } = req.params as { flagKey: string };
const productId = getRequestProductId(req);
const { limit } = req.query as { limit?: string };
const entries = await repo.listAuditLog(productId, flagKey, limit ? parseInt(limit, 10) : 50);
return { entries, total: entries.length };
});
// ── SSE stream ────────────────────────────────────────────────────────
app.get('/flags/stream', async (req, reply) => {
const productId = getRequestProductId(req);
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
reply.hijack();
const client: SseClient = {
productId,
write: (data: string) => reply.raw.write(data),
close: () => reply.raw.end(),
};
sseClients.add(client);
// Send initial connected event
reply.raw.write(`data: ${JSON.stringify({ type: 'connected', productId })}\n\n`);
// Heartbeat every 30s
const heartbeat = setInterval(() => {
try {
reply.raw.write(': heartbeat\n\n');
} catch {
clearInterval(heartbeat);
sseClients.delete(client);
}
}, 30_000);
req.raw.on('close', () => {
clearInterval(heartbeat);
sseClients.delete(client);
});
});
}

View File

@ -257,15 +257,30 @@ export async function seedDefaultFlags(logger?: { info: (msg: string) => void })
id: `flag_${productId}_${def.key}`,
productId,
key: def.key,
flagType: 'boolean',
enabled: def.enabled,
archived: false,
description: def.description,
tags: [],
variations: [
{ key: 'on', value: true, description: 'Flag is on' },
{ key: 'off', value: false, description: 'Flag is off' },
],
defaultVariation: 'on',
offVariation: 'off',
platforms: def.platforms,
regions: [],
osVersions: [],
segments: [],
targetingRules: [],
individualTargets: {},
prerequisites: [],
percentage: def.percentage,
createdAt: now,
updatedAt: now,
createdBy: 'system',
updatedBy: 'system',
version: 1,
};
await repo.create(doc);

View File

@ -1,60 +1,329 @@
/**
* Feature flags types extends kill switch to full feature flags.
* Ported from admin dashboard + desktop + mobile kill switch implementations.
* Feature flags types production-grade feature management system.
*
* Supports:
* - Multi-variate flags (boolean, string, number, JSON)
* - Targeting rules with attribute matching (AND/OR)
* - Percentage rollouts (deterministic via FNV-1a)
* - Platform / region / OS version targeting
* - User segment targeting with CRUD
* - Prerequisite flag dependencies
* - Scheduled flag activation/deactivation
* - Full audit trail (before/after snapshots)
* - SSE streaming for real-time updates
*/
import { z } from 'zod';
// ── Flag value types ────────────────────────────────────────────────────────
export type FlagType = 'boolean' | 'string' | 'number' | 'json';
/** A single variation for multi-variate flags. */
export interface FlagVariation {
key: string;
value: boolean | string | number | Record<string, unknown>;
description?: string;
}
// ── Targeting ───────────────────────────────────────────────────────────────
export type TargetingOperator =
| 'eq'
| 'neq'
| 'contains'
| 'not_contains'
| 'starts_with'
| 'ends_with'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'in'
| 'not_in'
| 'semver_gt'
| 'semver_gte'
| 'semver_lt'
| 'semver_lte'
| 'semver_eq'
| 'regex';
export interface TargetingClause {
attribute: string;
operator: TargetingOperator;
values: (string | number | boolean)[];
}
export interface TargetingRule {
id: string;
description?: string;
clauses: TargetingClause[];
variationKey: string;
rolloutPercentage: number;
}
export interface OsVersionRange {
platform: string;
minVersion?: string;
maxVersion?: string;
}
// ── Scheduling ──────────────────────────────────────────────────────────────
export interface FlagSchedule {
enableAt?: string;
disableAt?: string;
gradualRollout?: {
startPercentage: number;
endPercentage: number;
startAt: string;
endAt: string;
};
}
// ── Prerequisites ───────────────────────────────────────────────────────────
export interface FlagPrerequisite {
flagKey: string;
variationKey: string;
}
// ── Flag Document ───────────────────────────────────────────────────────────
export interface FeatureFlagDoc {
id: string;
productId: string;
key: string;
flagType: FlagType;
enabled: boolean;
archived: boolean;
description: string;
tags: string[];
variations: FlagVariation[];
defaultVariation: string;
offVariation: string;
platforms: string[];
regions: string[];
osVersions: OsVersionRange[];
segments: string[];
targetingRules: TargetingRule[];
individualTargets: Record<string, string>;
prerequisites: FlagPrerequisite[];
percentage: number;
schedule?: FlagSchedule;
createdAt: string;
updatedAt: string;
createdBy?: string;
updatedBy?: string;
version: number;
}
// ── Segment Document ────────────────────────────────────────────────────────
export interface SegmentDoc {
id: string;
productId: string;
key: string;
name: string;
description: string;
rules: TargetingClause[];
includedUsers: string[];
excludedUsers: string[];
createdAt: string;
updatedAt: string;
}
// ── Audit Log ───────────────────────────────────────────────────────────────
export interface FlagAuditDoc {
id: string;
productId: string;
flagKey: string;
action: 'created' | 'updated' | 'deleted' | 'toggled' | 'archived' | 'scheduled' | 'kill_switch';
actor: string;
before?: Partial<FeatureFlagDoc>;
after?: Partial<FeatureFlagDoc>;
changes?: string[];
timestamp: string;
}
// ── Evaluation Context ──────────────────────────────────────────────────────
export interface EvaluationContext {
userId?: string;
platform?: string;
region?: string;
osVersion?: string;
appVersion?: string;
email?: string;
custom?: Record<string, string | number | boolean>;
}
export interface EvaluationResult {
key: string;
value: boolean | string | number | Record<string, unknown>;
variationKey: string;
reason:
| 'off'
| 'prerequisite_failed'
| 'individual_target'
| 'rule_match'
| 'segment_match'
| 'percentage_rollout'
| 'default'
| 'schedule_inactive'
| 'error';
}
// ── Zod Schemas ─────────────────────────────────────────────────────────────
export const OsVersionRangeSchema = z.object({
platform: z.string().min(1),
minVersion: z.string().optional(),
maxVersion: z.string().optional(),
});
const TargetingClauseSchema = z.object({
attribute: z.string().min(1),
operator: z.enum([
'eq',
'neq',
'contains',
'not_contains',
'starts_with',
'ends_with',
'gt',
'gte',
'lt',
'lte',
'in',
'not_in',
'semver_gt',
'semver_gte',
'semver_lt',
'semver_lte',
'semver_eq',
'regex',
]),
values: z.array(z.union([z.string(), z.number(), z.boolean()])).min(1),
});
const TargetingRuleSchema = z.object({
id: z.string().min(1),
description: z.string().optional(),
clauses: z.array(TargetingClauseSchema).min(1),
variationKey: z.string().min(1),
rolloutPercentage: z.number().min(0).max(100).default(100),
});
const FlagVariationSchema = z.object({
key: z.string().min(1),
value: z.union([z.boolean(), z.string(), z.number(), z.record(z.unknown())]),
description: z.string().optional(),
});
const FlagScheduleSchema = z.object({
enableAt: z.string().datetime().optional(),
disableAt: z.string().datetime().optional(),
gradualRollout: z
.object({
startPercentage: z.number().min(0).max(100),
endPercentage: z.number().min(0).max(100),
startAt: z.string().datetime(),
endAt: z.string().datetime(),
})
.optional(),
});
const FlagPrerequisiteSchema = z.object({
flagKey: z.string().min(1),
variationKey: z.string().min(1),
});
export const CreateFlagSchema = z.object({
key: z
.string()
.min(1)
.regex(/^[a-z0-9_]+$/),
.regex(/^[a-z0-9_.]+$/),
flagType: z.enum(['boolean', 'string', 'number', 'json']).default('boolean'),
enabled: z.boolean().default(true),
description: z.string().default(''),
tags: z.array(z.string()).default([]),
variations: z.array(FlagVariationSchema).optional(),
defaultVariation: z.string().optional(),
offVariation: z.string().optional(),
platforms: z.array(z.string()).default([]),
regions: z.array(z.string()).default([]),
osVersions: z.array(OsVersionRangeSchema).default([]),
segments: z.array(z.string()).default([]),
targetingRules: z.array(TargetingRuleSchema).default([]),
individualTargets: z.record(z.string()).default({}),
prerequisites: z.array(FlagPrerequisiteSchema).default([]),
percentage: z.number().min(0).max(100).default(100),
schedule: FlagScheduleSchema.optional(),
});
export const UpdateFlagSchema = z.object({
enabled: z.boolean().optional(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
variations: z.array(FlagVariationSchema).optional(),
defaultVariation: z.string().optional(),
offVariation: z.string().optional(),
platforms: z.array(z.string()).optional(),
regions: z.array(z.string()).optional(),
osVersions: z.array(OsVersionRangeSchema).optional(),
segments: z.array(z.string()).optional(),
targetingRules: z.array(TargetingRuleSchema).optional(),
individualTargets: z.record(z.string()).optional(),
prerequisites: z.array(FlagPrerequisiteSchema).optional(),
percentage: z.number().min(0).max(100).optional(),
archived: z.boolean().optional(),
schedule: FlagScheduleSchema.optional(),
});
export const CreateSegmentSchema = z.object({
key: z
.string()
.min(1)
.regex(/^[a-z0-9_]+$/),
name: z.string().min(1),
description: z.string().default(''),
rules: z.array(TargetingClauseSchema).default([]),
includedUsers: z.array(z.string()).default([]),
excludedUsers: z.array(z.string()).default([]),
});
export const UpdateSegmentSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().optional(),
rules: z.array(TargetingClauseSchema).optional(),
includedUsers: z.array(z.string()).optional(),
excludedUsers: z.array(z.string()).optional(),
});
export const EvaluateSchema = z.object({
userId: z.string().optional(),
platform: z.string().optional(),
region: z.string().optional(),
osVersion: z.string().optional(),
appVersion: z.string().optional(),
email: z.string().optional(),
custom: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(),
});
export type CreateFlagInput = z.infer<typeof CreateFlagSchema>;
export type UpdateFlagInput = z.infer<typeof UpdateFlagSchema>;
export type CreateSegmentInput = z.infer<typeof CreateSegmentSchema>;
export type UpdateSegmentInput = z.infer<typeof UpdateSegmentSchema>;
export type EvaluateInput = z.infer<typeof EvaluateSchema>;

View File

@ -14,6 +14,10 @@ export const WEBHOOK_EVENTS = [
'referral.completed',
'waitlist.joined',
'flag.toggled',
'flag.created',
'flag.updated',
'flag.deleted',
'flag.kill_switch',
'license.activated',
'license.expired',
] as const;