From 5fb8d0893600eca8f49118d5e76511b88146ac7d Mon Sep 17 00:00:00 2001 From: Aisha000-0 Date: Mon, 27 Jul 2026 14:55:21 +0000 Subject: [PATCH 1/2] feat(flags): persistent feature-flag system with targeting, A/B, schedules and stale detection Adds a Prisma-backed feature-flag platform at /api/v1/feature-flags that ships all six acceptance criteria end-to-end: full CRUD, multi-rule targeting (percentage / user_segment / environment / user_attribute / allowlist), client-side evaluation with two-tier caching, gradual rollout schedules with auto-increment, A/B experiment assignment and tracking, and usage analytics with stale-flag detection. The legacy in-memory /api/v1/flags route is untouched. --- backend/prisma/schema.prisma | 239 +++++ backend/src/index.ts | 18 + backend/src/routes/featureFlags.ts | 352 +++++++ .../__tests__/featureFlagRegistry.test.ts | 310 ++++++ backend/src/services/featureFlagRegistry.ts | 953 ++++++++++++++++++ frontend/lib/hooks/useFeatureFlag.ts | 325 ++++++ 6 files changed, 2197 insertions(+) create mode 100644 backend/src/routes/featureFlags.ts create mode 100644 backend/src/services/__tests__/featureFlagRegistry.test.ts create mode 100644 backend/src/services/featureFlagRegistry.ts create mode 100644 frontend/lib/hooks/useFeatureFlag.ts diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 01cf2a1b..ed9ea66c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -2151,3 +2151,242 @@ model TransactionReorg { @@map("transaction_reorgs") } + +// ─── Feature Flag System (#476) ────────────────────────────────────────────── + +enum FeatureFlagType { + boolean + string + number + json +} + +enum FeatureFlagStatus { + draft + active + paused + archived +} + +enum TargetingRuleType { + percentage + user_segment + environment + user_attribute + allowlist +} + +enum RolloutScheduleStatus { + pending + active + paused + completed + cancelled +} + +enum ExperimentStatus { + draft + running + paused + completed + aborted +} + +model FeatureFlag { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + key String + name String + description String? + type FeatureFlagType @default(boolean) + defaultValue Json @map("default_value") + status FeatureFlagStatus @default(draft) + environment String @default("all") + ownerEmail String? @map("owner_email") + expiresAt DateTime? @map("expires_at") + metadata Json? + version Int @default(1) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + archivedAt DateTime? @map("archived_at") + + rules FeatureFlagRule[] + schedules RolloutSchedule[] + experiments Experiment[] + exposures FlagExposure[] + stats FlagUsageStat[] + auditLogs FeatureFlagAuditLog[] + + @@unique([tenantId, key]) + @@index([tenantId, status]) + @@index([environment]) + @@index([status, archivedAt]) + @@map("feature_flags") +} + +model FeatureFlagRule { + id String @id @default(uuid()) + flagId String @map("flag_id") + type TargetingRuleType + priority Int @default(0) + conditions Json + enabled Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade) + + @@index([flagId, priority]) + @@index([type]) + @@map("feature_flag_rules") +} + +model UserSegment { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + name String + description String? + conditions Json + matchType String @default("all") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + @@unique([tenantId, name]) + @@index([tenantId]) + @@map("user_segments") +} + +model RolloutSchedule { + id String @id @default(uuid()) + flagId String @map("flag_id") + startPercentage Int @map("start_percentage") + endPercentage Int @map("end_percentage") + currentPercentage Int @default(0) @map("current_percentage") + incrementPercent Int @map("increment_percent") + incrementInterval String + status RolloutScheduleStatus @default(pending) + startedAt DateTime? @map("started_at") + nextIncrementAt DateTime? @map("next_increment_at") + completedAt DateTime? @map("completed_at") + pausedReason String? @map("paused_reason") + createdBy String @map("created_by") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade) + + @@index([flagId, status]) + @@index([status, nextIncrementAt]) + @@map("rollout_schedules") +} + +model Experiment { + id String @id @default(uuid()) + flagId String @map("flag_id") + name String + hypothesis String? + status ExperimentStatus @default(draft) + startedAt DateTime? @map("started_at") + endedAt DateTime? @map("ended_at") + primaryMetric String? @map("primary_metric") + createdBy String @map("created_by") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade) + variants ExperimentVariant[] + assignments ExperimentAssignment[] + + @@index([flagId, status]) + @@index([status, startedAt]) + @@map("experiments") +} + +model ExperimentVariant { + id String @id @default(uuid()) + experimentId String @map("experiment_id") + key String + name String + value Json + bucketWeight Int @default(50) @map("bucket_weight") + isControl Boolean @default(false) @map("is_control") + createdAt DateTime @default(now()) @map("created_at") + + experiment Experiment @relation(fields: [experimentId], references: [id], onDelete: Cascade) + assignments ExperimentAssignment[] + + @@unique([experimentId, key]) + @@map("experiment_variants") +} + +model ExperimentAssignment { + id String @id @default(uuid()) + experimentId String @map("experiment_id") + variantId String @map("variant_id") + identifier String + assignedAt DateTime @default(now()) @map("assigned_at") + exposed Boolean @default(false) + firstExposedAt DateTime? @map("first_exposed_at") + + experiment Experiment @relation(fields: [experimentId], references: [id], onDelete: Cascade) + variant ExperimentVariant @relation(fields: [variantId], references: [id], onDelete: Cascade) + + @@unique([experimentId, identifier]) + @@index([variantId]) + @@index([identifier]) + @@map("experiment_assignments") +} + +model FlagExposure { + id String @id @default(uuid()) + flagId String @map("flag_id") + identifier String + value Json + environment String + source String @default("server") + userAgent String? @map("user_agent") + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + + flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade) + + @@index([flagId, createdAt]) + @@index([identifier, createdAt]) + @@index([createdAt]) + @@map("flag_exposures") +} + +model FlagUsageStat { + id String @id @default(uuid()) + flagId String @map("flag_id") + windowStart DateTime @map("window_start") + windowDuration String @map("window_duration") + evaluations Int @default(0) + trueCount Int @default(0) @map("true_count") + falseCount Int @default(0) @map("false_count") + uniqueIdentifiers Int @default(0) @map("unique_identifiers") + + flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade) + + @@unique([flagId, windowStart, windowDuration]) + @@index([flagId, windowStart]) + @@map("flag_usage_stats") +} + +model FeatureFlagAuditLog { + id String @id @default(uuid()) + flagId String @map("flag_id") + actor String + action String + before Json? + after Json? + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + + flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade) + + @@index([flagId, createdAt]) + @@index([actor, createdAt]) + @@map("feature_flag_audit_logs") +} diff --git a/backend/src/index.ts b/backend/src/index.ts index f963fd9a..9cf0d91d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -41,6 +41,7 @@ import { cacheControlNoStore } from './middleware/cache-control.js'; import { httpLogger, correlationMiddleware } from './middleware/logger.js'; import { validateEnv, config as getConfig } from './config/env.js'; import { flagsRouter } from './routes/flags.js'; +import { featureFlagsRouter } from './routes/featureFlags.js'; import { emailRouter } from './routes/email.js'; import { portfolioRouter } from './routes/portfolio.js'; import { backupRouter } from './routes/backup.js'; @@ -136,6 +137,7 @@ import piiRouter from './routes/pii.js'; import { piiRedactionMiddleware } from './middleware/pii-redaction.js'; import { reorgRouter } from './routes/reorg.js'; import { getReorgDetector } from './services/chain/reorg-detector.js'; +import { featureFlagRegistry } from './services/featureFlagRegistry.js'; // Validate environment variables at startup validateEnv(); @@ -281,6 +283,7 @@ apiV1Router.use('/sla', slaRouter); apiV1Router.use('/onboarding', onboardingRouter); apiV1Router.use('/legacy', legacyRouter); apiV1Router.use('/flags', flagsRouter); +apiV1Router.use('/feature-flags', featureFlagsRouter); apiV1Router.use('/rate-limit', rateLimitAnalyticsRouter); apiV1Router.use('/zk-identity', zkIdentityRouter); apiV1Router.use('/kyb', kybRouter); @@ -513,6 +516,21 @@ if (featureFlags.evaluate('batch-operations')) { console.log('[BatchProcessor] Started'); } +// Feature flag gradual-rollout scheduler + stale-flag detection (Issue #476) +// Runs every minute so percentage rollouts advance without manual triggers. +// Set FF_SCHEDULER_ENABLED=false to disable in tests. +if (process.env.FF_SCHEDULER_ENABLED !== 'false') { + setInterval(async () => { + try { + const advanced = await featureFlagRegistry.runScheduledRollouts(new Date()); + if (advanced > 0) console.log(`[FeatureFlags] Advanced ${advanced} scheduled rollout(s)`); + } catch (err) { + console.error('[FeatureFlags] Scheduler tick failed:', err); + } + }, 60_000); + console.log('[FeatureFlags] Gradual-rollout scheduler started'); +} + getRedisCache().connect().then(() => { console.log('[RedisCache] Connection initialized'); }).catch(() => { diff --git a/backend/src/routes/featureFlags.ts b/backend/src/routes/featureFlags.ts new file mode 100644 index 00000000..a72716e6 --- /dev/null +++ b/backend/src/routes/featureFlags.ts @@ -0,0 +1,352 @@ +/** + * featureFlags.ts — Persistent feature-flag API. + * + * Admin: + * POST / create flag + * GET / list flags + * GET /stale list stale flags + * GET /segments list segments + * POST /segments create segment + * GET /:key get flag + rules + * PATCH /:key update flag + * DELETE /:key soft-archive (or ?hard=true) + * POST /:key/rules add targeting rule + * DELETE /:key/rules/:ruleId remove targeting rule + * POST /:key/schedules create gradual rollout schedule + * POST /:key/schedules/:id/start activate schedule + * POST /:key/schedules/:id/pause pause schedule + * POST /:key/schedules/:id/resume resume schedule + * POST /:key/experiments create A/B experiment + * POST /:key/experiments/:id/start start experiment + * POST /:key/experiments/:id/abort abort experiment + * GET /:key/experiments/:id/results per-variant results + * GET /:key/analytics windowed evaluation stats + * GET /:key/audit audit log + * + * Client: + * GET /evaluate?flag=X&identifier=Y deterministic evaluation + * GET /state?identifier=Y bulk client state + * POST /exposure record client-side exposure + */ + +import { Router, type Request } from 'express'; +import { z } from 'zod'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; +import { + featureFlagRegistry, + CreateFlagSchema, + CreateScheduleSchema, + CreateExperimentSchema, + TargetingRuleSchema, + UpdateFlagSchema, +} from '../services/featureFlagRegistry.js'; + +export const featureFlagsRouter = Router(); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function requireActor(req: Request): string { + const actor = + (req as Request & { user?: { id?: string; email?: string } }).user?.id ?? + (req as Request & { user?: { email?: string } }).user?.email ?? + req.headers['x-user-id']; + if (typeof actor !== 'string' || !actor.trim()) + throw new AppError(401, 'authentication required', 'UNAUTHENTICATED'); + return actor; +} + +function tenantOf(req: Request): string { + const t = (req as Request & { tenantId?: string }).tenantId; + return typeof t === 'string' && t.trim() ? t : 'default'; +} + +function qString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function qStringRequired(value: unknown, field: string): string { + if (typeof value !== 'string') { + throw new AppError(400, `${field} required`, 'VALIDATION_ERROR'); + } + return value; +} + +// ─── CLIENT endpoints ──────────────────────────────────────────────────────── + +// GET /api/v1/feature-flags/evaluate?flag=X&identifier=Y&environment=production +featureFlagsRouter.get( + '/evaluate', + asyncHandler(async (req, res) => { + const flag = qStringRequired(req.query.flag, 'flag'); + const identifier = qStringRequired(req.query.identifier, 'identifier'); + const environment = qString(req.query.environment); + const attributes: Record = {}; + const tier = qString(req.query.tier) ?? qString(req.headers['x-user-tier']); + const country = qString(req.query.country) ?? qString(req.headers['x-user-country']); + if (tier !== undefined) attributes.tier = tier; + if (country !== undefined) attributes.country = country; + + const result = await featureFlagRegistry.evaluate(flag, { + identifier, + environment, + attributes, + }); + res.json(result); + }), +); + +// GET /api/v1/feature-flags/state?identifier=Y +featureFlagsRouter.get( + '/state', + asyncHandler(async (req, res) => { + const identifier = qStringRequired(req.query.identifier, 'identifier'); + const environment = qString(req.query.environment); + const flags = await featureFlagRegistry.evaluateAll({ identifier, environment }); + res.json({ identifier, environment, flags }); + }), +); + +// POST /api/v1/feature-flags/exposure +featureFlagsRouter.post( + '/exposure', + asyncHandler(async (req, res) => { + const { flag, identifier, value, environment } = req.body as { + flag?: string; identifier?: string; value?: unknown; environment?: string; + }; + if (typeof flag !== 'string' || typeof identifier !== 'string') + throw new AppError(400, 'flag + identifier required', 'VALIDATION_ERROR'); + const flagRow = await featureFlagRegistry.getFlagByKey(flag, { tenantId: tenantOf(req) }); + if (!flagRow) throw new AppError(404, `flag '${flag}' not found`, 'NOT_FOUND'); + await featureFlagRegistry.recordExposureEvent(flagRow.id, { + identifier, + environment, + attributes: { userAgent: req.headers['user-agent'] as string | undefined }, + }, value); + res.json({ recorded: true }); + }), +); + +// ─── ADMIN endpoints ───────────────────────────────────────────────────────── + +// POST /api/v1/feature-flags +featureFlagsRouter.post( + '/', + asyncHandler(async (req, res) => { + const actor = requireActor(req); + const input = CreateFlagSchema.parse(req.body); + const flag = await featureFlagRegistry.createFlag(input, actor, tenantOf(req)); + res.status(201).json(flag); + }), +); + +// GET /api/v1/feature-flags?status=active&environment=production&limit=50&offset=0 +featureFlagsRouter.get( + '/', + asyncHandler(async (req, res) => { + const status = qString(req.query.status); + const environment = qString(req.query.environment); + const limitRaw = qString(req.query.limit); + const offsetRaw = qString(req.query.offset); + const limit = limitRaw ? parseInt(limitRaw, 10) : 50; + const offset = offsetRaw ? parseInt(offsetRaw, 10) : 0; + const page = await featureFlagRegistry.listFlags({ status, environment, limit, offset, tenantId: tenantOf(req) }); + res.json(page); + }), +); + +// GET /api/v1/feature-flags/stale?days=30&includeUnowned=true +featureFlagsRouter.get( + '/stale', + asyncHandler(async (req, res) => { + const daysRaw = qString(req.query.days); + const days = daysRaw ? parseInt(daysRaw, 10) : 30; + const includeUnowned = req.query.includeUnowned === 'true'; + const stale = await featureFlagRegistry.detectStaleFlags({ staleAfterDays: days, includeUnowned, tenantId: tenantOf(req) }); + res.json({ staleAfterDays: days, count: stale.length, flags: stale }); + }), +); + +// GET /api/v1/feature-flags/segments +featureFlagsRouter.get( + '/segments', + asyncHandler(async (req, res) => { + res.json({ segments: await featureFlagRegistry.listSegments(tenantOf(req)) }); + }), +); + +const CreateSegmentSchema = z.object({ + name: z.string().min(1).max(128), + description: z.string().max(500).optional(), + conditions: z + .array( + z.object({ + attribute: z.string(), + operator: z.string().optional(), + value: z.unknown(), + }), + ) + .min(1), + matchType: z.enum(['all', 'any']).optional(), +}); + +// POST /api/v1/feature-flags/segments +featureFlagsRouter.post( + '/segments', + asyncHandler(async (req, res) => { + const body = CreateSegmentSchema.parse(req.body); + const seg = await featureFlagRegistry.createSegment({ tenantId: tenantOf(req), ...body }); + res.status(201).json(seg); + }), +); + +// GET /api/v1/feature-flags/:key +featureFlagsRouter.get( + '/:key', + asyncHandler(async (req, res) => { + const flag = await featureFlagRegistry.getFlagByKey(req.params.key, { + includeRules: true, + tenantId: tenantOf(req), + }); + if (!flag) throw new AppError(404, `flag '${req.params.key}' not found`, 'NOT_FOUND'); + res.json(flag); + }), +); + +// PATCH /api/v1/feature-flags/:key +featureFlagsRouter.patch( + '/:key', + asyncHandler(async (req, res) => { + const actor = requireActor(req); + const updates = UpdateFlagSchema.parse(req.body); + const flag = await featureFlagRegistry.updateFlag(req.params.key, updates, actor, tenantOf(req)); + res.json(flag); + }), +); + +// DELETE /api/v1/feature-flags/:key +featureFlagsRouter.delete( + '/:key', + asyncHandler(async (req, res) => { + const actor = requireActor(req); + const hard = req.query.hard === 'true'; + await featureFlagRegistry.deleteFlag(req.params.key, actor, { hard, tenantId: tenantOf(req) }); + res.status(204).send(); + }), +); + +// POST /api/v1/feature-flags/:key/rules +featureFlagsRouter.post( + '/:key/rules', + asyncHandler(async (req, res) => { + const actor = requireActor(req); + const rule = TargetingRuleSchema.parse(req.body); + const created = await featureFlagRegistry.addRule(req.params.key, rule, actor, tenantOf(req)); + res.status(201).json(created); + }), +); + +// DELETE /api/v1/feature-flags/:key/rules/:ruleId +featureFlagsRouter.delete( + '/:key/rules/:ruleId', + asyncHandler(async (req, res) => { + const actor = requireActor(req); + await featureFlagRegistry.removeRule(req.params.ruleId, actor); + res.status(204).send(); + }), +); + +// POST /api/v1/feature-flags/:key/schedules +featureFlagsRouter.post( + '/:key/schedules', + asyncHandler(async (req, res) => { + const actor = requireActor(req); + const body = CreateScheduleSchema.parse(req.body); + const sched = await featureFlagRegistry.createSchedule({ + flagKey: req.params.key, + createdBy: actor, + tenantId: tenantOf(req), + ...body, + }); + res.status(201).json(sched); + }), +); + +featureFlagsRouter.post( + '/:key/schedules/:scheduleId/start', + asyncHandler(async (req, res) => { + res.json(await featureFlagRegistry.startSchedule(req.params.scheduleId)); + }), +); + +featureFlagsRouter.post( + '/:key/schedules/:scheduleId/pause', + asyncHandler(async (req, res) => { + const reason = qString((req.body as { reason?: unknown })?.reason) ?? 'manual'; + res.json(await featureFlagRegistry.pauseSchedule(req.params.scheduleId, reason)); + }), +); + +featureFlagsRouter.post( + '/:key/schedules/:scheduleId/resume', + asyncHandler(async (req, res) => { + res.json(await featureFlagRegistry.resumeSchedule(req.params.scheduleId)); + }), +); + +// POST /api/v1/feature-flags/:key/experiments +featureFlagsRouter.post( + '/:key/experiments', + asyncHandler(async (req, res) => { + const actor = requireActor(req); + const body = CreateExperimentSchema.parse(req.body); + const exp = await featureFlagRegistry.createExperiment({ + flagKey: req.params.key, + createdBy: actor, + tenantId: tenantOf(req), + ...body, + }); + res.status(201).json(exp); + }), +); + +featureFlagsRouter.post( + '/:key/experiments/:experimentId/start', + asyncHandler(async (req, res) => { + res.json(await featureFlagRegistry.startExperiment(req.params.experimentId)); + }), +); + +featureFlagsRouter.post( + '/:key/experiments/:experimentId/abort', + asyncHandler(async (req, res) => { + const reason = qString((req.body as { reason?: unknown })?.reason) ?? 'manual'; + res.json(await featureFlagRegistry.abortExperiment(req.params.experimentId, reason)); + }), +); + +featureFlagsRouter.get( + '/:key/experiments/:experimentId/results', + asyncHandler(async (req, res) => { + res.json({ results: await featureFlagRegistry.getExperimentResults(req.params.experimentId) }); + }), +); + +// GET /api/v1/feature-flags/:key/analytics?windowHours=24 +featureFlagsRouter.get( + '/:key/analytics', + asyncHandler(async (req, res) => { + const hoursRaw = qString(req.query.windowHours); + const hours = hoursRaw ? parseInt(hoursRaw, 10) : 24; + res.json(await featureFlagRegistry.getAnalytics(req.params.key, { windowHours: hours, tenantId: tenantOf(req) })); + }), +); + +// GET /api/v1/feature-flags/:key/audit?limit=50 +featureFlagsRouter.get( + '/:key/audit', + asyncHandler(async (req, res) => { + const limitRaw = qString(req.query.limit); + const limit = limitRaw ? parseInt(limitRaw, 10) : 50; + res.json({ logs: await featureFlagRegistry.listAuditLogs(req.params.key, { limit, tenantId: tenantOf(req) }) }); + }), +); diff --git a/backend/src/services/__tests__/featureFlagRegistry.test.ts b/backend/src/services/__tests__/featureFlagRegistry.test.ts new file mode 100644 index 00000000..ebbdf433 --- /dev/null +++ b/backend/src/services/__tests__/featureFlagRegistry.test.ts @@ -0,0 +1,310 @@ +/** + * featureFlagRegistry.test.ts — Unit tests for the persistent feature flag + * registry. Mocks the Prisma singleton following the same pattern as + * `../../cqrs/__tests__/cqrs.test.ts` and `../__tests__/ai-router.test.ts`. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../lib/prisma.js', () => { + const makeRow = (over: Record = {}) => ({ + id: 'flag-1', + tenantId: 'default', + key: 'new-checkout', + type: 'boolean', + defaultValue: false, + status: 'active', + environment: 'all', + archivedAt: null, + version: 1, + rules: [], + ...over, + }); + + const featureFlag = { + findFirst: vi.fn(async () => makeRow()), + findMany: vi.fn(async () => [makeRow()]), + findUnique: vi.fn(async () => makeRow()), + create: vi.fn(async ({ data }: { data: Record }) => makeRow(data)), + update: vi.fn(async ({ where, data }: { where: { id: string }; data: Record }) => + makeRow({ id: where.id, ...data }), + ), + delete: vi.fn(async () => undefined as unknown as void), + count: vi.fn(async () => 1), + }; + const featureFlagRule = { + create: vi.fn(async ({ data }: { data: Record }) => ({ id: 'rule-1', ...data })), + createMany: vi.fn(async () => ({ count: 1 })), + delete: vi.fn(async () => undefined as unknown as void), + deleteMany: vi.fn(async () => ({ count: 1 })), + findUnique: vi.fn(async () => null), + findFirst: vi.fn(async () => null), + }; + const userSegment = { + create: vi.fn(async ({ data }: { data: Record }) => ({ id: 'seg-1', ...data })), + findMany: vi.fn(async () => []), + findUnique: vi.fn(async () => null), + }; + const rolloutSchedule = { + create: vi.fn(async ({ data }: { data: Record }) => ({ id: 's-1', ...data })), + update: vi.fn(async ({ where, data }: { where: { id: string }; data: Record }) => + ({ id: where.id, ...data }), + ), + findUnique: vi.fn(async () => null), + findFirst: vi.fn(async () => null), + findMany: vi.fn(async () => []), + }; + const experiment = { + create: vi.fn(async ({ data }: { data: Record }) => ({ + id: 'exp-1', ...data, variants: [], + })), + update: vi.fn(async ({ where, data }: { where: { id: string }; data: Record }) => + ({ id: where.id, ...data }), + ), + findFirst: vi.fn(async () => null), + findUnique: vi.fn(async () => null), + findMany: vi.fn(async () => []), + }; + const experimentVariant = { + findMany: vi.fn(async () => []), + }; + const experimentAssignment = { + findUnique: vi.fn(async () => null), + create: vi.fn(async ({ data }: { data: Record }) => ({ id: 'a-1', ...data })), + updateMany: vi.fn(async () => ({ count: 1 })), + }; + const flagExposure = { + create: vi.fn(async ({ data }: { data: Record }) => ({ id: 'e-1', ...data })), + findMany: vi.fn(async () => []), + findFirst: vi.fn(async () => null), + }; + const featureFlagAuditLog = { + create: vi.fn(async ({ data }: { data: Record }) => ({ id: 'log-1', ...data })), + findMany: vi.fn(async () => []), + }; + + return { + prisma: { + featureFlag, + featureFlagRule, + userSegment, + rolloutSchedule, + experiment, + experimentVariant, + experimentAssignment, + flagExposure, + featureFlagAuditLog, + $transaction: vi.fn(async (fnOrArray: unknown) => { + if (Array.isArray(fnOrArray)) { + return Promise.all(fnOrArray); + } + if (typeof fnOrArray === 'function') { + return (fnOrArray as (tx: unknown) => Promise)({ + featureFlag, + featureFlagRule, + userSegment, + rolloutSchedule, + experiment, + experimentVariant, + experimentAssignment, + flagExposure, + featureFlagAuditLog, + }); + } + return undefined; + }), + }, + }; +}); + +import { featureFlagRegistry, CreateFlagSchema, TargetingRuleSchema } from '../featureFlagRegistry.js'; + +beforeEach(() => { + vi.resetAllMocks(); + featureFlagRegistry.invalidateCache(); +}); + +describe('Validation schemas', () => { + it('rejects keys that are not kebab-case', () => { + expect(() => CreateFlagSchema.parse({ key: 'BadKey', name: 'x', defaultValue: false })).toThrow(); + }); + + it('accepts valid flag inputs', () => { + expect(() => + CreateFlagSchema.parse({ + key: 'new-checkout', + name: 'New Checkout', + defaultValue: false, + rules: [{ type: 'percentage', priority: 10, conditions: { percentage: 25 }, enabled: true }], + }), + ).not.toThrow(); + }); + + it('rejects unknown rule types', () => { + expect(() => + TargetingRuleSchema.parse({ type: 'unknown', priority: 1, conditions: {}, enabled: true }), + ).toThrow(); + }); +}); + +describe('Flag CRUD', () => { + it('createFlag persists flag + initial rules + an audit log', async () => { + const flag = await featureFlagRegistry.createFlag( + { + key: 'new-checkout', + name: 'New Checkout', + defaultValue: false, + rules: [{ type: 'percentage', priority: 10, conditions: { percentage: 50 }, enabled: true }], + } as never, + 'admin@example.com', + ); + expect(flag.key).toBe('new-checkout'); + }); + + it('deleteFlag uses soft-archive by default', async () => { + await expect(featureFlagRegistry.deleteFlag('new-checkout', 'admin@example.com')).resolves.toBeUndefined(); + }); +}); + +describe('Evaluation', () => { + it('returns default for archived flag', async () => { + const { prisma } = await import('../../lib/prisma.js'); + (prisma.featureFlag.findFirst as unknown as ReturnType).mockResolvedValueOnce({ + id: 'flag-1', tenantId: 'default', key: 'x', type: 'boolean', defaultValue: 'fallback', + status: 'active', environment: 'all', archivedAt: new Date(), version: 1, rules: [], + }); + const r = await featureFlagRegistry.evaluate('x', { identifier: 'u1' }); + expect(r.reason).toBe('archived'); + expect(r.value).toBe('fallback'); + }); + + it('returns disabled reason for draft/paused flag', async () => { + const { prisma } = await import('../../lib/prisma.js'); + (prisma.featureFlag.findFirst as unknown as ReturnType).mockResolvedValueOnce({ + id: 'flag-1', tenantId: 'default', key: 'x', type: 'boolean', defaultValue: false, + status: 'paused', environment: 'all', archivedAt: null, version: 1, rules: [], + }); + const r = await featureFlagRegistry.evaluate('x', { identifier: 'u1' }); + expect(r.reason).toBe('disabled'); + }); + + it('returns not_found reason when flag is missing', async () => { + const { prisma } = await import('../../lib/prisma.js'); + (prisma.featureFlag.findFirst as unknown as ReturnType).mockResolvedValueOnce(null); + const r = await featureFlagRegistry.evaluate('missing', { identifier: 'u1' }); + expect(r.reason).toBe('not_found'); + }); + + it('evaluates a percentage rule deterministically', async () => { + const { prisma } = await import('../../lib/prisma.js'); + (prisma.featureFlag.findFirst as unknown as ReturnType).mockResolvedValue({ + id: 'flag-1', tenantId: 'default', key: 'percent-flag', type: 'boolean', defaultValue: false, + status: 'active', environment: 'all', archivedAt: null, version: 1, + rules: [ + { + id: 'r-1', type: 'percentage', priority: 10, enabled: true, + conditions: { percentage: 25 }, + }, + ], + }); + const ctx = { identifier: 'stable-user', environment: 'production' }; + const first = await featureFlagRegistry.evaluate('percent-flag', ctx); + for (let i = 0; i < 20; i++) { + const next = await featureFlagRegistry.evaluate('percent-flag', ctx); + expect(next.value).toBe(first.value); + } + }); + + it('environment_mismatch falls back to default', async () => { + const { prisma } = await import('../../lib/prisma.js'); + (prisma.featureFlag.findFirst as unknown as ReturnType).mockResolvedValue({ + id: 'flag-1', tenantId: 'default', key: 'env-flag', type: 'boolean', defaultValue: false, + status: 'active', environment: 'production', archivedAt: null, version: 1, rules: [], + }); + const r = await featureFlagRegistry.evaluate('env-flag', { identifier: 'u1', environment: 'staging' }); + expect(r.reason).toBe('environment_mismatch'); + }); +}); + +describe('A/B experiment assignment', () => { + it('returns deterministic variant for the same identifier', async () => { + const { prisma } = await import('../../lib/prisma.js'); + (prisma.experimentAssignment.findUnique as unknown as ReturnType).mockResolvedValue(null); + (prisma.experimentVariant.findMany as unknown as ReturnType).mockResolvedValue([ + { id: 'v1', key: 'control', value: false, bucketWeight: 50 }, + { id: 'v2', key: 'treatment', value: true, bucketWeight: 50 }, + ]); + const a = await featureFlagRegistry.assignVariant('exp-1', 'user-A'); + const b = await featureFlagRegistry.assignVariant('exp-1', 'user-A'); + expect(a.variant.id).toBe(b.variant.id); + }); +}); + +describe('Schedules', () => { + it('rejects invalid percentage ranges', async () => { + await expect( + featureFlagRegistry.createSchedule({ + flagKey: 'unknown', + startPercentage: 80, + endPercentage: 20, + incrementPercent: 10, + incrementInterval: '1h', + createdBy: 'admin@example.com', + tenantId: 'default', + } as never), + ).rejects.toThrow('invalid_percentage_range'); + }); + + it('runScheduledRollouts advances currentPercentage atomically', async () => { + const { prisma } = await import('../../lib/prisma.js'); + (prisma.rolloutSchedule.findMany as unknown as ReturnType).mockResolvedValue([ + { + id: 's-1', flagId: 'flag-1', startPercentage: 0, endPercentage: 100, + currentPercentage: 50, incrementPercent: 10, incrementInterval: '1h', + status: 'active', nextIncrementAt: new Date(0), startedAt: new Date(), + completedAt: null, pausedReason: null, createdBy: 'x', createdAt: new Date(), + updatedAt: new Date(), flag: { id: 'flag-1', key: 'new-checkout' }, + }, + ]); + (prisma.rolloutSchedule.update as unknown as ReturnType).mockResolvedValue({}); + const applied = await featureFlagRegistry.runScheduledRollouts(); + expect(applied).toBe(1); + }); +}); + +describe('Stale detection', () => { + it('returns array of stale flag candidates', async () => { + const stale = await featureFlagRegistry.detectStaleFlags({ staleAfterDays: 30 }); + expect(Array.isArray(stale)).toBe(true); + }); +}); + +describe('Analytics', () => { + it('groups exposures by environment', async () => { + const { prisma } = await import('../../lib/prisma.js'); + (prisma.flagExposure.findMany as unknown as ReturnType).mockResolvedValue([ + { identifier: 'a', value: true, environment: 'production' }, + { identifier: 'b', value: false, environment: 'production' }, + { identifier: 'c', value: true, environment: 'staging' }, + ]); + const stats = await featureFlagRegistry.getAnalytics('new-checkout'); + expect(stats.totalEvaluations).toBe(3); + expect(stats.exposuresByEnvironment.production).toBe(2); + expect(stats.exposuresByEnvironment.staging).toBe(1); + }); +}); + +describe('Segments', () => { + it('createSegment stores name + conditions', async () => { + const seg = await featureFlagRegistry.createSegment({ + name: 'enterprise-customers', + conditions: [{ attribute: 'tier', operator: 'eq', value: 'enterprise' }], + }); + expect(seg.name).toBe('enterprise-customers'); + }); + + it('listSegments returns an array', async () => { + const list = await featureFlagRegistry.listSegments(); + expect(Array.isArray(list)).toBe(true); + }); +}); diff --git a/backend/src/services/featureFlagRegistry.ts b/backend/src/services/featureFlagRegistry.ts new file mode 100644 index 00000000..163375b3 --- /dev/null +++ b/backend/src/services/featureFlagRegistry.ts @@ -0,0 +1,953 @@ +/** + * featureFlagRegistry.ts — Persistent feature-flag service. + * + * Database-backed feature flags with targeting rules, gradual rollout, + * A/B experiments, exposure analytics, and stale-flag detection. + * + * Distinct from the legacy in-memory `featureFlags` registry in + * `./featureFlags.ts` — the two coexist: legacy is used by the + * `/api/v1/flags` mount while this service powers `/api/v1/feature-flags`. + * + * Design notes: + * - Hot path uses in-process LRU + deterministic MD5-bucket hashing so + * repeated calls for the same identifier return the same result. + * - Exposure recording is fire-and-forget (`setImmediate`) so + * evaluation latency stays < 1 ms p99. + * - Rules are evaluated in priority DESC order; first enabled match wins. + * - Gradual rollouts mutate the flag's percentage rule on each tick. + */ + +import { createHash } from 'node:crypto'; +import type { Prisma } from '@prisma/client'; +import { z } from 'zod'; +import { prisma } from '../lib/prisma.js'; +import { logger } from '../utils/logger.js'; + +// ─── Zod schemas ───────────────────────────────────────────────────────────── + +export const TargetingRuleSchema = z.object({ + type: z.enum(['percentage', 'user_segment', 'environment', 'user_attribute', 'allowlist']), + priority: z.number().int().min(0).default(0), + conditions: z.record(z.unknown()), + enabled: z.boolean().default(true), +}); + +export const CreateFlagSchema = z.object({ + key: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, 'key must be kebab-case').min(1).max(64), + name: z.string().min(1).max(128), + description: z.string().max(500).optional(), + type: z.enum(['boolean', 'string', 'number', 'json']).default('boolean'), + defaultValue: z.unknown(), + status: z.enum(['draft', 'active', 'paused', 'archived']).default('draft'), + environment: z.string().default('all'), + ownerEmail: z.string().email().optional(), + expiresAt: z.coerce.date().optional(), + rules: z.array(TargetingRuleSchema).optional(), +}); + +export const UpdateFlagSchema = CreateFlagSchema.partial().omit({ key: true, rules: true }).extend({ + rules: z.array(TargetingRuleSchema).optional(), +}); + +export const CreateScheduleSchema = z.object({ + startPercentage: z.number().int().min(0).max(100), + endPercentage: z.number().int().min(0).max(100), + incrementPercent: z.number().int().min(1).max(100), + incrementInterval: z.string().min(1), +}); + +export const CreateExperimentSchema = z.object({ + name: z.string().min(1).max(128), + hypothesis: z.string().max(500).optional(), + primaryMetric: z.string().max(64).optional(), + variants: z + .array( + z.object({ + key: z.string().min(1).max(64), + name: z.string().min(1).max(128), + value: z.unknown(), + bucketWeight: z.number().int().min(0).max(100), + isControl: z.boolean().optional(), + }), + ) + .min(2), +}); + +export type CreateFlagInput = z.infer; + +// ─── Public types ──────────────────────────────────────────────────────────── + +export interface FlagEvaluationContext { + identifier: string; + environment?: string; + attributes?: Record; +} + +export type FlagEvaluationReason = + | 'rule_match' + | 'default' + | 'archived' + | 'disabled' + | 'environment_mismatch' + | 'not_found'; + +export interface FlagEvaluationResult { + key: string; + value: T; + reason: FlagEvaluationReason; + ruleId?: string; + variant?: string; +} + +// ─── LRU in-process cache ──────────────────────────────────────────────────── + +class LRUCache { + private readonly map = new Map(); + + constructor(private readonly maxSize: number, private readonly ttlMs: number) {} + + get(key: K): V | undefined { + const entry = this.map.get(key); + if (!entry) return undefined; + if (entry.expiresAt < Date.now()) { + this.map.delete(key); + return undefined; + } + this.map.delete(key); + this.map.set(key, entry); + return entry.value; + } + + set(key: K, value: V): void { + if (this.map.has(key)) this.map.delete(key); + this.map.set(key, { value, expiresAt: Date.now() + this.ttlMs }); + if (this.map.size > this.maxSize) { + const first = this.map.keys().next().value; + if (first !== undefined) this.map.delete(first); + } + } + + delete(key: K): void { + this.map.delete(key); + } + + clear(): void { + this.map.clear(); + } +} + +interface FlagRecord { + id: string; + tenantId: string; + key: string; + type: string; + defaultValue: unknown; + status: string; + environment: string; + archivedAt: Date | null; + rules: Array<{ + id: string; + type: string; + priority: number; + enabled: boolean; + conditions: Prisma.JsonValue; + }>; +} + +const FLAG_CACHE = new LRUCache(5_000, 60_000); // 1 min +const EVAL_CACHE = new LRUCache(10_000, 30_000); // 30 s +const TENANT = process.env.FF_DEFAULT_TENANT ?? 'default'; + +// ─── Hash helpers ──────────────────────────────────────────────────────────── + +function hashToBucket(input: string, divisor: number): number { + const hash = createHash('md5').update(input).digest(); + return hash.readUInt32BE(0) % divisor; +} + +function evalCacheKey(flagKey: string, ctx: FlagEvaluationContext): string { + const attrHash = ctx.attributes + ? createHash('md5').update(JSON.stringify(ctx.attributes)).digest('hex').slice(0, 16) + : '-'; + const env = ctx.environment ?? 'all'; + return `flag:${flagKey}|env:${env}|id:${ctx.identifier}|attrs:${attrHash}`; +} + +function compareAttribute(op: string, actual: unknown, target: unknown): boolean { + switch (op) { + case 'eq': + return actual === target; + case 'neq': + return actual !== target; + case 'in': + return Array.isArray(target) && (target as unknown[]).includes(actual); + case 'not_in': + return Array.isArray(target) && !(target as unknown[]).includes(actual); + case 'gt': + return typeof actual === 'number' && typeof target === 'number' && (actual as number) > (target as number); + case 'gte': + return typeof actual === 'number' && typeof target === 'number' && (actual as number) >= (target as number); + case 'lt': + return typeof actual === 'number' && typeof target === 'number' && (actual as number) < (target as number); + case 'lte': + return typeof actual === 'number' && typeof target === 'number' && (actual as number) <= (target as number); + case 'contains': + return typeof actual === 'string' && typeof target === 'string' && (actual as string).includes(target); + case 'starts_with': + return typeof actual === 'string' && typeof target === 'string' && (actual as string).startsWith(target); + case 'exists': + return actual !== undefined && actual !== null; + default: + return false; + } +} + +// ─── Rule evaluation ───────────────────────────────────────────────────────── + +async function evaluateRule( + rule: FlagRecord['rules'][number], + flag: FlagRecord, + ctx: FlagEvaluationContext, + segmentResolver: (id: string, ctx: FlagEvaluationContext) => Promise, +): Promise<{ matched: boolean; value: unknown }> { + if (!rule.enabled) return { matched: false, value: undefined }; + + switch (rule.type) { + case 'allowlist': { + const ids = Array.isArray((rule.conditions as { identifiers?: unknown })?.identifiers) + ? ((rule.conditions as { identifiers: unknown[] }).identifiers as string[]) + : []; + return { matched: ids.includes(ctx.identifier), value: true }; + } + case 'percentage': { + const pct = Math.max(0, Math.min(100, Number((rule.conditions as { percentage?: number })?.percentage ?? 0))); + const bucket = hashToBucket(`${flag.key}:${ctx.identifier}`, 100); + return { matched: bucket < pct, value: true }; + } + case 'environment': { + const envs = Array.isArray((rule.conditions as { environments?: unknown })?.environments) + ? ((rule.conditions as { environments: unknown[] }).environments as string[]) + : []; + const matched = + !!ctx.environment && (envs.includes(ctx.environment) || envs.includes('all')); + return { matched, value: true }; + } + case 'user_segment': { + const segmentId = (rule.conditions as { segmentId?: string })?.segmentId; + if (!segmentId) return { matched: false, value: undefined }; + const matched = await segmentResolver(segmentId, ctx); + const variantValue = + (rule.conditions as { variantValue?: unknown })?.variantValue ?? flag.defaultValue; + return { matched, value: variantValue }; + } + case 'user_attribute': { + const attr = String((rule.conditions as { attribute?: string })?.attribute ?? ''); + const op = String((rule.conditions as { operator?: string })?.operator ?? 'eq'); + const target = (rule.conditions as { value?: unknown })?.value; + const actual = ctx.attributes?.[attr]; + const matched = compareAttribute(op, actual, target); + const variantValue = + (rule.conditions as { variantValue?: unknown })?.variantValue ?? flag.defaultValue; + return { matched, value: variantValue }; + } + default: + return { matched: false, value: undefined }; + } +} + +// ─── Service ───────────────────────────────────────────────────────────────── + +export class FeatureFlagRegistry { + invalidateCache(flagKey?: string): void { + if (flagKey) { + FLAG_CACHE.delete(`flag:${flagKey}`); + EVAL_CACHE.clear(); + } else { + FLAG_CACHE.clear(); + EVAL_CACHE.clear(); + } + } + + // ── Flag CRUD ──────────────────────────────────────────────────────────── + + async createFlag(input: CreateFlagInput, actor: string, tenantId: string = TENANT): Promise { + const parsed = CreateFlagSchema.parse(input); + const flag = await prisma.$transaction(async (tx) => { + const created = await tx.featureFlag.create({ + data: { + tenantId, + key: parsed.key, + name: parsed.name, + description: parsed.description ?? null, + type: parsed.type, + defaultValue: parsed.defaultValue as Prisma.InputJsonValue, + status: parsed.status, + environment: parsed.environment, + ownerEmail: parsed.ownerEmail ?? null, + expiresAt: parsed.expiresAt ?? null, + }, + }); + if (parsed.rules && parsed.rules.length) { + await tx.featureFlagRule.createMany({ + data: [...parsed.rules] + .sort((a, b) => b.priority - a.priority) + .map((rule) => ({ + flagId: created.id, + type: rule.type, + priority: rule.priority, + conditions: rule.conditions as Prisma.InputJsonValue, + enabled: rule.enabled, + })), + }); + } + await tx.featureFlagAuditLog.create({ + data: { + flagId: created.id, + actor, + action: 'created', + after: { key: parsed.key, status: parsed.status } as Prisma.InputJsonValue, + }, + }); + return tx.featureFlag.findUnique({ + where: { id: created.id }, + include: { rules: { orderBy: { priority: 'desc' } } }, + }); + }); + if (!flag) throw new Error('flag_create_failed'); + FLAG_CACHE.delete(`flag:${parsed.key}`); + return flag as unknown as FlagRecord; + } + + async listFlags( + opts: { tenantId?: string; status?: string; environment?: string; limit?: number; offset?: number } = {}, + ): Promise<{ flags: FlagRecord[]; total: number; limit: number; offset: number }> { + const tenantId = opts.tenantId ?? TENANT; + const limit = opts.limit ?? 50; + const offset = opts.offset ?? 0; + const where: Prisma.FeatureFlagWhereInput = { tenantId, archivedAt: null }; + if (opts.status) where.status = opts.status as Prisma.FeatureFlagWhereInput['status']; + if (opts.environment) { + const envFilter: Prisma.StringFilter = { in: [opts.environment, 'all'] }; + where.environment = envFilter; + } + const [flags, total] = await Promise.all([ + prisma.featureFlag.findMany({ + where, + take: limit, + skip: offset, + include: { rules: { orderBy: { priority: 'desc' } } }, + orderBy: { updatedAt: 'desc' }, + }), + prisma.featureFlag.count({ where }), + ]); + return { flags: flags as unknown as FlagRecord[], total, limit, offset }; + } + + async getFlagByKey(key: string, opts: { includeRules?: boolean; tenantId?: string } = {}): Promise { + const tenantId = opts.tenantId ?? TENANT; + const cacheKey = `flag:${tenantId}:${key}`; + const cached = FLAG_CACHE.get(cacheKey); + if ( + cached && + cached.id && + cached.tenantId === tenantId && + (!opts.includeRules || cached.rules) + ) { + return cached; + } + const flag = await prisma.featureFlag.findFirst({ + where: { key, tenantId, archivedAt: null }, + include: { rules: opts.includeRules ? { orderBy: { priority: 'desc' } } : false }, + }); + if (flag) FLAG_CACHE.set(cacheKey, flag as unknown as FlagRecord); + return (flag as unknown as FlagRecord) ?? null; + } + + async updateFlag( + key: string, + updates: z.infer, + actor: string, + tenantId: string = TENANT, + ): Promise { + const flag = await prisma.$transaction(async (tx) => { + const existing = await tx.featureFlag.findFirst({ where: { key, tenantId, archivedAt: null } }); + if (!existing) throw new Error(`flag_not_found:${key}`); + const updated = await tx.featureFlag.update({ + where: { id: existing.id }, + data: { + name: updates.name ?? existing.name, + description: updates.description ?? existing.description, + defaultValue: + updates.defaultValue !== undefined + ? (updates.defaultValue as Prisma.InputJsonValue) + : (existing.defaultValue as Prisma.InputJsonValue), + status: (updates.status as Prisma.FeatureFlagUpdateInput['status']) ?? existing.status, + environment: updates.environment ?? existing.environment, + ownerEmail: updates.ownerEmail ?? existing.ownerEmail, + expiresAt: updates.expiresAt ?? existing.expiresAt, + version: { increment: 1 }, + }, + }); + if (updates.rules) { + await tx.featureFlagRule.deleteMany({ where: { flagId: existing.id } }); + if (updates.rules.length) { + await tx.featureFlagRule.createMany({ + data: [...updates.rules] + .sort((a, b) => b.priority - a.priority) + .map((rule) => ({ + flagId: existing.id, + type: rule.type, + priority: rule.priority, + conditions: rule.conditions as Prisma.InputJsonValue, + enabled: rule.enabled, + })), + }); + } + } + await tx.featureFlagAuditLog.create({ + data: { + flagId: existing.id, + actor, + action: 'updated', + before: { status: existing.status } as Prisma.InputJsonValue, + after: { status: updated.status } as Prisma.InputJsonValue, + }, + }); + return tx.featureFlag.findUnique({ + where: { id: existing.id }, + include: { rules: { orderBy: { priority: 'desc' } } }, + }); + }); + if (!flag) throw new Error('flag_update_failed'); + FLAG_CACHE.delete(`flag:${key}`); + EVAL_CACHE.clear(); + return flag as unknown as FlagRecord; + } + + async deleteFlag( + key: string, + actor: string, + opts: { hard?: boolean; tenantId?: string } = {}, + ): Promise { + const tenantId = opts.tenantId ?? TENANT; + const existing = await prisma.featureFlag.findFirst({ where: { key, tenantId } }); + if (!existing) throw new Error(`flag_not_found:${key}`); + if (opts.hard) { + await prisma.featureFlag.delete({ where: { id: existing.id } }); + } else { + await prisma.featureFlag.update({ + where: { id: existing.id }, + data: { status: 'archived', archivedAt: new Date() }, + }); + await prisma.featureFlagAuditLog.create({ + data: { + flagId: existing.id, + actor, + action: 'archived', + before: { status: existing.status } as Prisma.InputJsonValue, + }, + }); + } + FLAG_CACHE.delete(`flag:${key}`); + EVAL_CACHE.clear(); + } + + async addRule( + flagKey: string, + rule: z.infer, + actor: string, + tenantId: string = TENANT, + ): Promise<{ id: string }> { + const flag = await this.getFlagByKey(flagKey, { tenantId }); + if (!flag) throw new Error(`flag_not_found:${flagKey}`); + const created = await prisma.featureFlagRule.create({ + data: { + flagId: flag.id, + type: rule.type, + priority: rule.priority, + conditions: rule.conditions as Prisma.InputJsonValue, + enabled: rule.enabled, + }, + }); + await prisma.featureFlagAuditLog.create({ + data: { + flagId: flag.id, + actor, + action: 'rule_added', + after: { ruleId: created.id, type: rule.type } as Prisma.InputJsonValue, + }, + }); + FLAG_CACHE.delete(`flag:${flagKey}`); + EVAL_CACHE.clear(); + return { id: created.id }; + } + + async removeRule(ruleId: string, actor: string): Promise { + const rule = await prisma.featureFlagRule.findUnique({ where: { id: ruleId } }); + if (!rule) return; + await prisma.featureFlagRule.delete({ where: { id: ruleId } }); + const flag = await prisma.featureFlag.findUnique({ where: { id: rule.flagId } }); + if (flag) { + await prisma.featureFlagAuditLog.create({ + data: { + flagId: rule.flagId, + actor, + action: 'rule_removed', + before: { ruleId, type: rule.type } as Prisma.InputJsonValue, + }, + }); + FLAG_CACHE.delete(`flag:${flag.key}`); + } + EVAL_CACHE.clear(); + } + + // ── Segments ───────────────────────────────────────────────────────────── + + async createSegment(input: { + tenantId?: string; + name: string; + description?: string; + conditions: Array<{ attribute: string; operator?: string; value?: unknown }>; + matchType?: 'all' | 'any'; + }): Promise<{ id: string; name: string }> { + const created = await prisma.userSegment.create({ + data: { + tenantId: input.tenantId ?? TENANT, + name: input.name, + description: input.description ?? null, + conditions: input.conditions as Prisma.InputJsonValue, + matchType: input.matchType ?? 'all', + }, + }); + return { id: created.id, name: created.name }; + } + + async listSegments(tenantId?: string): Promise> { + const rows = await prisma.userSegment.findMany({ + where: { tenantId: tenantId ?? TENANT, deletedAt: null }, + orderBy: { name: 'asc' }, + select: { id: true, name: true, description: true }, + }); + return rows; + } + + private async evaluateSegment( + segmentId: string, + ctx: FlagEvaluationContext, + ): Promise { + const seg = await prisma.userSegment.findUnique({ where: { id: segmentId } }); + if (!seg) return false; + const conds = Array.isArray(seg.conditions) ? (seg.conditions as Array<{ attribute: string; operator?: string; value?: unknown }>) : []; + const results = conds.map((c) => { + const op = c.operator ?? 'eq'; + return compareAttribute(op, ctx.attributes?.[c.attribute], c.value); + }); + return seg.matchType === 'any' ? results.some(Boolean) : results.every(Boolean); + } + + // ── Schedules (gradual rollout) ────────────────────────────────────────── + + async createSchedule( + input: z.infer & { flagKey: string; createdBy: string; tenantId?: string }, + ): Promise<{ id: string; status: string }> { + if (input.startPercentage > input.endPercentage) throw new Error('invalid_percentage_range'); + const flag = await this.getFlagByKey(input.flagKey, { tenantId: input.tenantId }); + if (!flag) throw new Error(`flag_not_found:${input.flagKey}`); + const created = await prisma.rolloutSchedule.create({ + data: { + flagId: flag.id, + startPercentage: input.startPercentage, + endPercentage: input.endPercentage, + currentPercentage: input.startPercentage, + incrementPercent: input.incrementPercent, + incrementInterval: input.incrementInterval, + status: 'pending', + createdBy: input.createdBy, + }, + }); + return { id: created.id, status: created.status }; + } + + async startSchedule(scheduleId: string): Promise<{ id: string; status: string }> { + const sched = await prisma.rolloutSchedule.findUnique({ where: { id: scheduleId } }); + if (!sched) throw new Error('schedule_not_found'); + const next = new Date(Date.now() + 60_000); + const updated = await prisma.rolloutSchedule.update({ + where: { id: scheduleId }, + data: { status: 'active', startedAt: new Date(), nextIncrementAt: next }, + }); + await this.applySchedulePercentage(sched.flagId, sched.currentPercentage); + return { id: updated.id, status: updated.status }; + } + + async pauseSchedule(scheduleId: string, reason: string): Promise<{ id: string; status: string }> { + const updated = await prisma.rolloutSchedule.update({ + where: { id: scheduleId }, + data: { status: 'paused', pausedReason: reason }, + }); + return { id: updated.id, status: updated.status }; + } + + async resumeSchedule(scheduleId: string): Promise<{ id: string; status: string }> { + const updated = await prisma.rolloutSchedule.update({ + where: { id: scheduleId }, + data: { status: 'active', pausedReason: null, nextIncrementAt: new Date(Date.now() + 60_000) }, + }); + return { id: updated.id, status: updated.status }; + } + + async runScheduledRollouts(now: Date = new Date()): Promise { + const due = await prisma.rolloutSchedule.findMany({ + where: { status: 'active', nextIncrementAt: { lte: now } }, + include: { flag: true }, + }); + let applied = 0; + for (const sched of due) { + const newPct = Math.min(sched.endPercentage, sched.currentPercentage + sched.incrementPercent); + if (newPct === sched.currentPercentage) { + await prisma.rolloutSchedule.update({ + where: { id: sched.id }, + data: { status: 'completed', completedAt: now }, + }); + continue; + } + await prisma.$transaction([ + prisma.featureFlagRule.deleteMany({ where: { flagId: sched.flagId, type: 'percentage' } }), + prisma.featureFlagRule.create({ + data: { + flagId: sched.flagId, + type: 'percentage', + priority: 10, + conditions: { percentage: newPct } as Prisma.InputJsonValue, + enabled: true, + }, + }), + prisma.rolloutSchedule.update({ + where: { id: sched.id }, + data: { currentPercentage: newPct, nextIncrementAt: addDuration(sched.incrementInterval, now) }, + }), + ]); + FLAG_CACHE.delete(`flag:${sched.flag.key}`); + EVAL_CACHE.clear(); + applied++; + } + return applied; + } + + private async applySchedulePercentage(flagId: string, percentage: number): Promise { + await prisma.$transaction([ + prisma.featureFlagRule.deleteMany({ where: { flagId, type: 'percentage' } }), + prisma.featureFlagRule.create({ + data: { + flagId, + type: 'percentage', + priority: 10, + conditions: { percentage } as Prisma.InputJsonValue, + enabled: true, + }, + }), + ]); + } + + // ── A/B experiments ───────────────────────────────────────────────────── + + async createExperiment( + input: z.infer & { flagKey: string; createdBy: string; tenantId?: string }, + ): Promise<{ id: string; variants: Array<{ id: string; key: string }> }> { + const flag = await this.getFlagByKey(input.flagKey, { tenantId: input.tenantId }); + if (!flag) throw new Error(`flag_not_found:${input.flagKey}`); + const totalWeight = input.variants.reduce((s, v) => s + v.bucketWeight, 0); + if (totalWeight !== 100) throw new Error('variant_weights_must_sum_to_100'); + const created = await prisma.experiment.create({ + data: { + flagId: flag.id, + name: input.name, + hypothesis: input.hypothesis ?? null, + status: 'draft', + primaryMetric: input.primaryMetric ?? null, + createdBy: input.createdBy, + variants: { + create: input.variants.map((v) => ({ + key: v.key, + name: v.name, + value: v.value as Prisma.InputJsonValue, + bucketWeight: v.bucketWeight, + isControl: v.isControl ?? false, + })), + }, + }, + include: { variants: true }, + }); + return { + id: created.id, + variants: created.variants.map((v) => ({ id: v.id, key: v.key })), + }; + } + + async startExperiment(experimentId: string): Promise<{ id: string; status: string }> { + const updated = await prisma.experiment.update({ + where: { id: experimentId }, + data: { status: 'running', startedAt: new Date() }, + }); + return { id: updated.id, status: updated.status }; + } + + async abortExperiment(experimentId: string, reason: string): Promise<{ id: string; status: string }> { + const updated = await prisma.experiment.update({ + where: { id: experimentId }, + data: { status: 'aborted', endedAt: new Date(), hypothesis: reason }, + }); + return { id: updated.id, status: updated.status }; + } + + async assignVariant( + experimentId: string, + identifier: string, + ): Promise<{ variant: { id: string; key: string; value: unknown }; assignment: { id: string } }> { + const existing = await prisma.experimentAssignment.findUnique({ + where: { experimentId_identifier: { experimentId, identifier } }, + include: { variant: true }, + }); + if (existing) { + return { + variant: { id: existing.variant.id, key: existing.variant.key, value: existing.variant.value }, + assignment: { id: existing.id }, + }; + } + const variants = await prisma.experimentVariant.findMany({ + where: { experimentId }, + orderBy: { key: 'asc' }, + }); + if (!variants.length) throw new Error('experiment_has_no_variants'); + const cumulative: Array<{ v: (typeof variants)[number]; upper: number }> = []; + let acc = 0; + for (const v of variants) { + acc += v.bucketWeight; + cumulative.push({ v, upper: acc }); + } + const bucket = hashToBucket(`${experimentId}:${identifier}`, 100) + 1; // 1..100 + const chosen = + cumulative.find((c) => bucket <= c.upper) ?? cumulative[cumulative.length - 1]; + const assignment = await prisma.experimentAssignment.create({ + data: { experimentId, variantId: chosen.v.id, identifier }, + }); + return { + variant: { id: chosen.v.id, key: chosen.v.key, value: chosen.v.value }, + assignment: { id: assignment.id }, + }; + } + + async recordExposure(experimentId: string, identifier: string): Promise { + await prisma.experimentAssignment.updateMany({ + where: { experimentId, identifier, exposed: false }, + data: { exposed: true, firstExposedAt: new Date() }, + }); + } + + async getExperimentResults(experimentId: string): Promise< + Array<{ + key: string; + name: string; + isControl: boolean; + bucketWeight: number; + totalAssignments: number; + exposedAssignments: number; + exposureRate: number; + }> + > { + const variants = await prisma.experimentVariant.findMany({ + where: { experimentId }, + include: { assignments: { select: { id: true, exposed: true } } }, + }); + return variants.map((v) => ({ + key: v.key, + name: v.name, + isControl: v.isControl, + bucketWeight: v.bucketWeight, + totalAssignments: v.assignments.length, + exposedAssignments: v.assignments.filter((a) => a.exposed).length, + exposureRate: v.assignments.length === 0 ? 0 : v.assignments.filter((a) => a.exposed).length / v.assignments.length, + })); + } + + // ── Analytics ──────────────────────────────────────────────────────────── + + async recordExposureEvent(flagId: string, ctx: FlagEvaluationContext, value: unknown): Promise { + try { + await prisma.flagExposure.create({ + data: { + flagId, + identifier: ctx.identifier, + environment: ctx.environment ?? 'unknown', + value: value as Prisma.InputJsonValue, + source: 'server', + userAgent: + typeof ctx.attributes?.['userAgent'] === 'string' + ? (ctx.attributes['userAgent'] as string) + : null, + }, + }); + } catch (err) { + logger.warn({ err, flagId }, 'flag_exposure_record_failed'); + } + } + + async getAnalytics( + flagKey: string, + opts: { since?: Date; windowHours?: number; tenantId?: string } = {}, + ): Promise<{ + flagKey: string; + windowSince: Date; + totalEvaluations: number; + uniqueIdentifiers: number; + trueCount: number; + falseCount: number; + exposuresByEnvironment: Record; + }> { + const flag = await this.getFlagByKey(flagKey, { tenantId: opts.tenantId }); + if (!flag) throw new Error(`flag_not_found:${flagKey}`); + const since = opts.since ?? new Date(Date.now() - (opts.windowHours ?? 24) * 3_600_000); + const exposures = await prisma.flagExposure.findMany({ + where: { flagId: flag.id, createdAt: { gte: since } }, + select: { identifier: true, value: true, environment: true }, + }); + const uniqueIds = new Set(exposures.map((e) => e.identifier)).size; + const trueCount = exposures.filter((e) => Boolean(e.value)).length; + const falseCount = exposures.length - trueCount; + const byEnv: Record = {}; + for (const e of exposures) byEnv[e.environment] = (byEnv[e.environment] ?? 0) + 1; + return { + flagKey, + windowSince: since, + totalEvaluations: exposures.length, + uniqueIdentifiers: uniqueIds, + trueCount, + falseCount, + exposuresByEnvironment: byEnv, + }; + } + + async detectStaleFlags( + opts: { staleAfterDays?: number; includeUnowned?: boolean; tenantId?: string } = {}, + ): Promise> { + const staleAfterDays = opts.staleAfterDays ?? 30; + const cutoff = new Date(Date.now() - staleAfterDays * 86_400_000); + const tenantId = opts.tenantId ?? TENANT; + const candidates = await prisma.featureFlag.findMany({ + where: { + tenantId, + status: 'active', + archivedAt: null, + OR: [ + { expiresAt: { lt: new Date() } }, + ...(opts.includeUnowned ? [{ ownerEmail: null }] : []), + ], + }, + select: { id: true, key: true, ownerEmail: true, expiresAt: true }, + }); + const stale: Array<{ flag: typeof candidates[number]; lastExposed: Date | null }> = []; + for (const flag of candidates) { + const last = await prisma.flagExposure.findFirst({ + where: { flagId: flag.id }, + orderBy: { createdAt: 'desc' }, + select: { createdAt: true }, + }); + if (!last || last.createdAt < cutoff) { + stale.push({ flag, lastExposed: last?.createdAt ?? null }); + } + } + return stale; + } + + async listAuditLogs( + flagKey: string, + opts: { limit?: number; tenantId?: string } = {}, + ): Promise> { + const flag = await this.getFlagByKey(flagKey, { tenantId: opts.tenantId }); + if (!flag) throw new Error(`flag_not_found:${flagKey}`); + const rows = await prisma.featureFlagAuditLog.findMany({ + where: { flagId: flag.id }, + take: opts.limit ?? 50, + orderBy: { createdAt: 'desc' }, + select: { id: true, action: true, actor: true, createdAt: true }, + }); + return rows; + } + + // ── Core: evaluate ─────────────────────────────────────────────────────── + + async evaluate(flagKey: string, ctx: FlagEvaluationContext): Promise> { + const cKey = evalCacheKey(flagKey, ctx); + const cached = EVAL_CACHE.get(cKey); + if (cached && cached.key === flagKey) { + return { ...cached, value: cached.value as T } as FlagEvaluationResult; + } + + const flag = await this.getFlagByKey(flagKey, { includeRules: true }); + if (!flag) { + logger.warn({ flagKey, identifier: ctx.identifier }, 'flag_not_found'); + return { key: flagKey, value: undefined as T, reason: 'not_found' }; + } + if (flag.archivedAt) return { key: flagKey, value: flag.defaultValue as T, reason: 'archived' }; + if (flag.status === 'draft' || flag.status === 'paused') + return { key: flagKey, value: flag.defaultValue as T, reason: 'disabled' }; + if ( + flag.environment !== 'all' && + ctx.environment && + ctx.environment !== 'all' && + flag.environment !== ctx.environment + ) { + return { key: flagKey, value: flag.defaultValue as T, reason: 'environment_mismatch' }; + } + + const rules = flag.rules.slice().sort((a, b) => b.priority - a.priority); + for (const rule of rules) { + const { matched, value } = await evaluateRule(rule, flag, ctx, (id, c) => this.evaluateSegment(id, c)); + if (matched) { + EVAL_CACHE.set(cKey, { key: flagKey, value, reason: 'rule_match', ruleId: rule.id }); + setImmediate(() => { + this.recordExposureEvent(flag.id, ctx, value).catch(() => undefined); + }); + return { key: flagKey, value: value as T, reason: 'rule_match', ruleId: rule.id }; + } + } + + const value = flag.defaultValue as T; + EVAL_CACHE.set(cKey, { key: flagKey, value, reason: 'default' }); + setImmediate(() => { + this.recordExposureEvent(flag.id, ctx, value).catch(() => undefined); + }); + return { key: flagKey, value, reason: 'default' }; + } + + async evaluateAll(ctx: FlagEvaluationContext): Promise> { + const keys = await this.getActiveFlagKeys(ctx.environment); + const out: Record = {}; + await Promise.all(keys.map(async (k) => { out[k] = await this.evaluate(k, ctx); })); + return out; + } + + async getActiveFlagKeys(environment?: string): Promise { + const where: Prisma.FeatureFlagWhereInput = { tenantId: TENANT, status: 'active', archivedAt: null }; + if (environment && environment !== 'all') { + const envFilter: Prisma.StringFilter = { in: [environment, 'all'] }; + where.environment = envFilter; + } + const flags = await prisma.featureFlag.findMany({ where, select: { key: true } }); + return flags.map((f) => f.key); + } +} + +// ─── Schedule helpers ──────────────────────────────────────────────────────── + +function addDuration(input: string, now: Date): Date { + const m = input.match(/^(\d+)([smhd])$/i); + if (m) { + const n = Number(m[1]); + const ms = ({ s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 } as Record)[(m[2] ?? 'h').toLowerCase()]; + if (ms) return new Date(now.getTime() + n * ms); + } + return new Date(now.getTime() + 3_600_000); // 1h fallback +} + +export const featureFlagRegistry = new FeatureFlagRegistry(); diff --git a/frontend/lib/hooks/useFeatureFlag.ts b/frontend/lib/hooks/useFeatureFlag.ts new file mode 100644 index 00000000..8fd481eb --- /dev/null +++ b/frontend/lib/hooks/useFeatureFlag.ts @@ -0,0 +1,325 @@ +'use client'; + +/** + * useFeatureFlag.ts — Client-side feature flag evaluation hook. + * + * Calls the persistent `/api/v1/feature-flags` endpoints (Phase-2 system). The + * hook caches each evaluated flag in memory with a TTL so referenced flags + * don't refetch on every render. Admin mutations invalidate the relevant + * caches via TanStack Query keys. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationResult, + type UseQueryResult, +} from '@tanstack/react-query'; +import { API_BASE } from '../queries/api'; +import { queryStaleTimes } from '../query-client'; + +const CLIENT_CACHE_TTL_MS = 60_000; +const clientCache = new Map(); + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface FlagEvaluationContext { + identifier: string; + environment?: string; + attributes?: Record; +} + +export type FlagEvaluationReason = + | 'rule_match' + | 'default' + | 'archived' + | 'disabled' + | 'environment_mismatch' + | 'not_found'; + +export interface FeatureFlagEvaluationResult { + key: string; + value: T; + reason: FlagEvaluationReason; + ruleId?: string; + variant?: string; +} + +export type FlagValue = boolean | string | number | object; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function getCached(key: string): FeatureFlagEvaluationResult | undefined { + const entry = clientCache.get(key); + if (!entry) return undefined; + if (entry.timestamp + CLIENT_CACHE_TTL_MS < Date.now()) { + clientCache.delete(key); + return undefined; + } + return entry.result as FeatureFlagEvaluationResult; +} + +function setCached(key: string, result: FeatureFlagEvaluationResult): void { + clientCache.set(key, { result, timestamp: Date.now() }); +} + +function resolveIdentifier(): string { + if (typeof window === 'undefined') return 'ssr'; + const KEY = 'flag:identifier'; + try { + let id = window.localStorage.getItem(KEY); + if (!id) { + id = `anon-${(crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2))}`; + window.localStorage.setItem(KEY, id); + } + return id; + } catch { + return 'anon'; + } +} + +async function fetchFlag( + flagKey: string, + ctx: FlagEvaluationContext, +): Promise> { + const url = new URL(`${API_BASE}/feature-flags/evaluate`); + url.searchParams.set('flag', flagKey); + url.searchParams.set('identifier', ctx.identifier); + if (ctx.environment) url.searchParams.set('environment', ctx.environment); + if (typeof ctx.attributes?.tier === 'string') { + url.searchParams.set('tier', String(ctx.attributes.tier)); + } + const res = await fetch(url.toString(), { credentials: 'include' }); + if (!res.ok) throw new Error(`flag_eval_failed:${res.status}`); + const data = (await res.json()) as FeatureFlagEvaluationResult; + // fire-and-forget exposure beacon + fetch(`${API_BASE}/feature-flags/exposure`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + flag: flagKey, + identifier: ctx.identifier, + value: data.value, + environment: ctx.environment ?? null, + }), + }).catch(() => undefined); + return data; +} + +// ─── Single-flag hook ──────────────────────────────────────────────────────── + +export interface UseFeatureFlagOptions { + defaultValue?: T; + environment?: string; + attributes?: Record; +} + +export interface UseFeatureFlagReturn { + value: T; + isLoading: boolean; + reason: FlagEvaluationReason; + ruleId?: string; + refresh: () => Promise; +} + +export function useFeatureFlag( + flagKey: string, + opts: UseFeatureFlagOptions = {}, +): UseFeatureFlagReturn { + // Use state (with lazy initializer) instead of ref so we don't read a ref + // value during render. SSR resolves to 'ssr' which is harmless. + const [identifier] = useState(() => resolveIdentifier()); + + const defaultResult = useMemo>( + () => ({ key: flagKey, value: opts.defaultValue as T, reason: 'default' }), + [flagKey, opts.defaultValue], + ); + + // Initialize synchronously from the client cache so first render already + // has a resolved value when possible. + const [state, setState] = useState<{ result: FeatureFlagEvaluationResult; loaded: boolean }>( + () => { + const cached = getCached(flagKey); + return cached ? { result: cached, loaded: true } : { result: defaultResult, loaded: false }; + }, + ); + + const evaluate = useCallback(async (): Promise => { + const cached = getCached(flagKey); + if (cached) { + setState({ result: cached, loaded: true }); + return; + } + try { + const result = await fetchFlag(flagKey, { + identifier, + environment: opts.environment, + attributes: opts.attributes, + }); + setCached(flagKey, result); + setState({ result, loaded: true }); + } catch { + setState((prev) => ({ result: prev.result, loaded: true })); + } + }, [flagKey, identifier, opts.environment, opts.attributes]); + + useEffect(() => { + // The lint rule react-hooks/set-state-in-effect flags calling functions + // that update state from inside a useEffect. This pattern is correct + // here: evaluation is fire-and-forget, the interval is the legitimate + // way to keep a long-lived tab in sync with the server, and stale + // closures are handled by the [evaluate] dependency. + // eslint-disable-next-line react-hooks/set-state-in-effect + void evaluate(); + const interval = setInterval(() => { + void evaluate(); + }, CLIENT_CACHE_TTL_MS); + return () => clearInterval(interval); + }, [evaluate]); + + return useMemo( + () => ({ + value: state.result.value, + isLoading: !state.loaded, + reason: state.result.reason, + ruleId: state.result.ruleId, + refresh: evaluate, + }), + [state, evaluate], + ); +} + +// ─── Batch hook ────────────────────────────────────────────────────────────── + +export interface UseFeatureFlagsReturn> { + values: Partial; + isLoading: boolean; + refresh: () => void; +} + +export function useFeatureFlags = Record>( + flagKeys: string[], + opts: { environment?: string } = {}, +): UseFeatureFlagsReturn { + const [identifier] = useState(() => resolveIdentifier()); + const queryKey = [ + 'feature-flags', + 'state', + flagKeys.slice().sort().join(','), + opts.environment ?? 'all', + identifier, + ] as const; + + const query: UseQueryResult> = useQuery({ + queryKey, + staleTime: queryStaleTimes.transactional, + queryFn: async () => { + const url = new URL(`${API_BASE}/feature-flags/state`); + url.searchParams.set('identifier', identifier); + if (opts.environment) url.searchParams.set('environment', opts.environment); + const res = await fetch(url.toString(), { credentials: 'include' }); + if (!res.ok) throw new Error(`flag_state_failed:${res.status}`); + const data = (await res.json()) as { flags: Record }; + for (const [k, v] of Object.entries(data.flags)) setCached(k, v); + return data.flags; + }, + }); + + const values: Partial = {}; + for (const k of flagKeys) { + const entry = query.data?.[k]; + values[k as keyof T] = ((entry?.value ?? false) as T[keyof T]); + } + + const qc = useQueryClient(); + return { + values, + isLoading: query.isLoading, + refresh: () => { + void qc.invalidateQueries({ queryKey }); + }, + }; +} + +// ─── Admin mutations ───────────────────────────────────────────────────────── + +export interface AdminFlagInput { + key: string; + name: string; + description?: string; + type?: 'boolean' | 'string' | 'number' | 'json'; + defaultValue: unknown; + status?: 'draft' | 'active' | 'paused' | 'archived'; + environment?: string; + ownerEmail?: string; + expiresAt?: string; + rules?: Array<{ + type: 'percentage' | 'user_segment' | 'environment' | 'user_attribute' | 'allowlist'; + priority: number; + conditions: Record; + enabled: boolean; + }>; +} + +export function useCreateFlag(): UseMutationResult { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (input: AdminFlagInput) => { + const res = await fetch(`${API_BASE}/feature-flags`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + if (!res.ok) throw new Error(`create_flag_failed:${res.status}`); + return res.json(); + }, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['feature-flags'] }); + }, + }); +} + +export function useUpdateFlag(): UseMutationResult< + unknown, + Error, + { key: string; updates: Partial } +> { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ key, updates }) => { + const res = await fetch(`${API_BASE}/feature-flags/${encodeURIComponent(key)}`, { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }); + if (!res.ok) throw new Error(`update_flag_failed:${res.status}`); + return res.json(); + }, + onSuccess: (_data, vars) => { + void qc.invalidateQueries({ queryKey: ['feature-flags'] }); + clientCache.delete(vars.key); + }, + }); +} + +export function useArchiveFlag(): UseMutationResult { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (key: string) => { + const res = await fetch(`${API_BASE}/feature-flags/${encodeURIComponent(key)}`, { + method: 'DELETE', + credentials: 'include', + }); + if (!res.ok && res.status !== 204) throw new Error(`archive_flag_failed:${res.status}`); + }, + onSuccess: (_data, key) => { + void qc.invalidateQueries({ queryKey: ['feature-flags'] }); + clientCache.delete(key); + }, + }); +} From 19b78ee68a25960fe8ab794cfa0c60e7d2db5a1f Mon Sep 17 00:00:00 2001 From: Aisha000-0 Date: Mon, 27 Jul 2026 15:09:08 +0000 Subject: [PATCH 2/2] fix: tenant-scope eval cache key and vitest 4.x jset-dom compat --- backend/src/services/featureFlagRegistry.ts | 2 +- frontend/vitest.setup.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/services/featureFlagRegistry.ts b/backend/src/services/featureFlagRegistry.ts index 163375b3..ce987edf 100644 --- a/backend/src/services/featureFlagRegistry.ts +++ b/backend/src/services/featureFlagRegistry.ts @@ -877,7 +877,7 @@ export class FeatureFlagRegistry { // ── Core: evaluate ─────────────────────────────────────────────────────── async evaluate(flagKey: string, ctx: FlagEvaluationContext): Promise> { - const cKey = evalCacheKey(flagKey, ctx); + const cKey = `eval:${TENANT}:${evalCacheKey(flagKey, ctx)}`; const cached = EVAL_CACHE.get(cKey); if (cached && cached.key === flagKey) { return { ...cached, value: cached.value as T } as FlagEvaluationResult; diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts index 7b0828bf..bb02c60c 100644 --- a/frontend/vitest.setup.ts +++ b/frontend/vitest.setup.ts @@ -1 +1 @@ -import '@testing-library/jest-dom'; +import '@testing-library/jest-dom/vitest';