diff --git a/packages/events/src/types.ts b/packages/events/src/types.ts index c1bd856f..56a0cc88 100644 --- a/packages/events/src/types.ts +++ b/packages/events/src/types.ts @@ -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 diff --git a/packages/feature-flag-client/src/client.ts b/packages/feature-flag-client/src/client.ts index 360021d1..ba95e790 100644 --- a/packages/feature-flag-client/src/client.ts +++ b/packages/feature-flag-client/src/client.ts @@ -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 = {}; + let boolFlags: Record = {}; + let evaluations: Record = {}; let initialized = false; let intervalId: ReturnType | null = null; + // eslint-disable-next-line no-undef + let eventSource: InstanceType | 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 { + function buildHeaders(): Record { + const requestId = + typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + + const headers: Record = { + '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 { 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 }; - 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 { + try { + const body: Record = { 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 }; + 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 { + 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 { 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>( + 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> { - return flags; + return boolFlags; + } + + function getAllEvaluations(): Readonly> { + return evaluations; } async function refresh(): Promise { - 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, + }; } diff --git a/packages/feature-flag-client/src/index.ts b/packages/feature-flag-client/src/index.ts index e3a7aaf1..cd426b39 100644 --- a/packages/feature-flag-client/src/index.ts +++ b/packages/feature-flag-client/src/index.ts @@ -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'; diff --git a/packages/feature-flag-client/src/types.ts b/packages/feature-flag-client/src/types.ts index e0ca4e1c..cda332ae 100644 --- a/packages/feature-flag-client/src/types.ts +++ b/packages/feature-flag-client/src/types.ts @@ -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; +} + +export interface EvaluationResult { + key: string; + value: boolean | string | number | Record; + 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; - /** 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>( + 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>; + /** Get all evaluation results (multi-variate format). */ + getAllEvaluations(): Readonly>; + /** Force a refresh of feature flags. */ refresh(): Promise; - /** 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; } diff --git a/services/platform-service/src/lib/event-dispatcher.ts b/services/platform-service/src/lib/event-dispatcher.ts index e250de60..ec02c53f 100644 --- a/services/platform-service/src/lib/event-dispatcher.ts +++ b/services/platform-service/src/lib/event-dispatcher.ts @@ -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', diff --git a/services/platform-service/src/modules/flags/evaluator.ts b/services/platform-service/src/modules/flags/evaluator.ts new file mode 100644 index 00000000..63fd6b56 --- /dev/null +++ b/services/platform-service/src/modules/flags/evaluator.ts @@ -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 → 0–99 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; +} + +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 { + const results: Record = {}; + const activeFlags = flags.filter(f => !f.archived); + + for (const flag of activeFlags) { + results[flag.key] = evaluateFlag({ + flag, + ctx, + allFlags: activeFlags, + segments, + now, + }); + } + + return results; +} diff --git a/services/platform-service/src/modules/flags/flags.test.ts b/services/platform-service/src/modules/flags/flags.test.ts index 33dab7aa..ce107b1a 100644 --- a/services/platform-service/src/modules/flags/flags.test.ts +++ b/services/platform-service/src/modules/flags/flags.test.ts @@ -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 { + 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 { + 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(); 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++) { diff --git a/services/platform-service/src/modules/flags/repository.ts b/services/platform-service/src/modules/flags/repository.ts index c42d60a6..5e313d4d 100644 --- a/services/platform-service/src/modules/flags/repository.ts +++ b/services/platform-service/src/modules/flags/repository.ts @@ -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('feature_flags', '/productId'); } export async function list(productId: string): Promise { - return collection().findMany({ + return flagCollection().findMany({ filter: { productId }, sort: { key: 1 }, }); } export async function getByKey(key: string, productId: string): Promise { - return collection().findOne({ + return flagCollection().findOne({ filter: { productId, key }, }); } export async function create(doc: FeatureFlagDoc): Promise { - return collection().create(doc); + return flagCollection().create(doc); } export async function update( @@ -31,7 +38,10 @@ export async function update( updates: Partial ): Promise { 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 { try { - await collection().delete(id, id); + await flagCollection().delete(id, id); return true; } catch { return false; } } + +// ── Segments ──────────────────────────────────────────────────────────────── + +function segmentCollection() { + return getCollection('flag_segments', '/productId'); +} + +export async function listSegments(productId: string): Promise { + return segmentCollection().findMany({ + filter: { productId }, + sort: { key: 1 }, + }); +} + +export async function getSegmentByKey(key: string, productId: string): Promise { + return segmentCollection().findOne({ + filter: { productId, key }, + }); +} + +export async function createSegment(doc: SegmentDoc): Promise { + return segmentCollection().create(doc); +} + +export async function updateSegment( + id: string, + updates: Partial +): Promise { + try { + return await segmentCollection().update(id, id, { + ...updates, + updatedAt: new Date().toISOString(), + }); + } catch { + return null; + } +} + +export async function removeSegment(id: string): Promise { + try { + await segmentCollection().delete(id, id); + return true; + } catch { + return false; + } +} + +// ── Audit Log ─────────────────────────────────────────────────────────────── + +function auditCollection() { + return getCollection('flag_audit_log', '/productId'); +} + +export async function createAuditEntry(doc: FlagAuditDoc): Promise { + return auditCollection().create(doc); +} + +export async function listAuditLog( + productId: string, + flagKey?: string, + limit = 50 +): Promise { + const filter = flagKey ? { productId, flagKey } : { productId }; + + const all = await auditCollection().findMany({ + filter, + sort: { timestamp: -1 }, + }); + return all.slice(0, limit); +} diff --git a/services/platform-service/src/modules/flags/routes.ts b/services/platform-service/src/modules/flags/routes.ts index 2aedfbdf..7ac843bd 100644 --- a/services/platform-service/src/modules/flags/routes.ts +++ b/services/platform-service/src/modules/flags/routes.ts @@ -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, after: Partial): 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, + after?: Partial +): Promise { + 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 = 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 = {}; - 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); + }); + }); } diff --git a/services/platform-service/src/modules/flags/seed.ts b/services/platform-service/src/modules/flags/seed.ts index 6cb3bcfd..a9aeb55a 100644 --- a/services/platform-service/src/modules/flags/seed.ts +++ b/services/platform-service/src/modules/flags/seed.ts @@ -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); diff --git a/services/platform-service/src/modules/flags/types.ts b/services/platform-service/src/modules/flags/types.ts index ed8f83e8..da834d63 100644 --- a/services/platform-service/src/modules/flags/types.ts +++ b/services/platform-service/src/modules/flags/types.ts @@ -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; + 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; + 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; + after?: Partial; + changes?: string[]; + timestamp: string; +} + +// ── Evaluation Context ────────────────────────────────────────────────────── + +export interface EvaluationContext { + userId?: string; + platform?: string; + region?: string; + osVersion?: string; + appVersion?: string; + email?: string; + custom?: Record; +} + +export interface EvaluationResult { + key: string; + value: boolean | string | number | Record; + 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; export type UpdateFlagInput = z.infer; +export type CreateSegmentInput = z.infer; +export type UpdateSegmentInput = z.infer; +export type EvaluateInput = z.infer; diff --git a/services/platform-service/src/modules/webhooks/types.ts b/services/platform-service/src/modules/webhooks/types.ts index a5a71fe4..125e082a 100644 --- a/services/platform-service/src/modules/webhooks/types.ts +++ b/services/platform-service/src/modules/webhooks/types.ts @@ -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;