From b122f44513cce8b32d543e7303df29176fec5af2 Mon Sep 17 00:00:00 2001 From: Marvelous Samuel Date: Mon, 31 Aug 2026 10:35:15 +0100 Subject: [PATCH 1/6] feat: Add deterministic percentage rollout to feature flags (#1187) --- src/lib/feature-flags/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/feature-flags/index.ts b/src/lib/feature-flags/index.ts index 03ef5350..fce32109 100644 --- a/src/lib/feature-flags/index.ts +++ b/src/lib/feature-flags/index.ts @@ -2,4 +2,4 @@ * src/lib/feature-flags — public barrel */ export type { FeatureFlag, TargetingRule, AuditEntry, RolloutStrategy } from './store'; -export { flagStore, auditLog, evaluateFlag, createAuditEntry, generateId } from './store'; +export { flagStore, auditLog, evaluateFlag, createAuditEntry, generateId, getPercentageBucket } from './store'; From 52b23bc77db94cf9fd001f2450dfef26d360482d Mon Sep 17 00:00:00 2001 From: Marvelous Samuel Date: Mon, 31 Aug 2026 10:35:17 +0100 Subject: [PATCH 2/6] feat: Add deterministic percentage rollout to feature flags (#1187) --- src/lib/feature-flags/store.ts | 178 +-------------------------------- 1 file changed, 2 insertions(+), 176 deletions(-) diff --git a/src/lib/feature-flags/store.ts b/src/lib/feature-flags/store.ts index 33fd3119..b0b2cabc 100644 --- a/src/lib/feature-flags/store.ts +++ b/src/lib/feature-flags/store.ts @@ -2,183 +2,9 @@ * Feature Flag types and in-process store. * * Persistence: flags are kept in a module-level Map so they survive - * across API requests within the same Node.js process (dev / single + * across API requests within the same NodeJs process (dev / single * instance prod). Replace `flagStore` / `auditStore` with a database * client for multi-instance deployments. */ -// ─── Core types ─────────────────────────────────────────────────────────────── - -export type RolloutStrategy = 'all' | 'percentage' | 'targeting'; - -export interface TargetingRule { - /** e.g. "userId", "email", "country", "plan" */ - attribute: string; - operator: 'equals' | 'contains' | 'startsWith' | 'in'; - /** string or comma-separated list for 'in' */ - value: string; -} - -export interface FeatureFlag { - id: string; - name: string; - description: string; - enabled: boolean; - strategy: RolloutStrategy; - /** 0–100, used when strategy === 'percentage' */ - percentage: number; - /** used when strategy === 'targeting' */ - rules: TargetingRule[]; - tags: string[]; - createdAt: string; - updatedAt: string; - createdBy: string; -} - -export interface AuditEntry { - id: string; - flagId: string; - flagName: string; - action: 'created' | 'updated' | 'deleted' | 'toggled'; - actor: string; - before: Partial | null; - after: Partial | null; - timestamp: string; -} - -// ─── In-process stores ──────────────────────────────────────────────────────── - -export const flagStore = new Map(); -export const auditLog: AuditEntry[] = []; - -// ─── Seed with sensible defaults ────────────────────────────────────────────── - -const now = new Date().toISOString(); - -const SEED_FLAGS: FeatureFlag[] = [ - { - id: 'flag_new_dashboard', - name: 'New Dashboard', - description: 'Enables the redesigned learner dashboard.', - enabled: false, - strategy: 'percentage', - percentage: 10, - rules: [], - tags: ['ui', 'dashboard'], - createdAt: now, - updatedAt: now, - createdBy: 'system', - }, - { - id: 'flag_ai_tutor', - name: 'AI Tutor', - description: 'Activates the AI-powered tutoring assistant.', - enabled: false, - strategy: 'targeting', - percentage: 0, - rules: [{ attribute: 'plan', operator: 'equals', value: 'pro' }], - tags: ['ai', 'beta'], - createdAt: now, - updatedAt: now, - createdBy: 'system', - }, - { - id: 'flag_video_speed', - name: 'Video Speed Controls', - description: 'Shows advanced playback speed options (0.5×–3×) in the video player.', - enabled: true, - strategy: 'all', - percentage: 100, - rules: [], - tags: ['video', 'ux'], - createdAt: now, - updatedAt: now, - createdBy: 'system', - }, -]; - -for (const f of SEED_FLAGS) { - flagStore.set(f.id, f); -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -export function generateId(prefix = ''): string { - const uuid = crypto.randomUUID(); - return prefix ? `${prefix}_${uuid}` : uuid; -} - -export function createAuditEntry( - action: AuditEntry['action'], - actor: string, - before: FeatureFlag | null, - after: FeatureFlag | null, -): AuditEntry { - const entry: AuditEntry = { - id: generateId('audit'), - flagId: (after ?? before)!.id, - flagName: (after ?? before)!.name, - action, - actor, - before: before ? { ...before } : null, - after: after ? { ...after } : null, - timestamp: new Date().toISOString(), - }; - // Keep last 500 audit entries - auditLog.unshift(entry); - if (auditLog.length > 500) auditLog.length = 500; - return entry; -} - -/** - * Evaluate whether a flag is active for a given user context. - * Context is a flat key→value map (e.g. { userId, plan, country }). - */ -export function evaluateFlag(flag: FeatureFlag, context: Record = {}): boolean { - if (!flag.enabled) return false; - - switch (flag.strategy) { - case 'all': - return true; - - case 'percentage': { - if (flag.percentage >= 100) return true; - if (flag.percentage <= 0) return false; - // Deterministic per-user bucket via userId hash - const userId = context.userId ?? ''; - let hash = 0; - for (let i = 0; i < (flag.id + userId).length; i++) { - hash = Math.imul(31, hash) + (flag.id + userId).charCodeAt(i); - hash |= 0; - } - const bucket = Math.abs(hash) % 100; - return bucket < flag.percentage; - } - - case 'targeting': { - if (flag.rules.length === 0) return false; - // ALL rules must match (AND logic) - return flag.rules.every((rule) => { - const attrVal = context[rule.attribute] ?? ''; - switch (rule.operator) { - case 'equals': - return attrVal === rule.value; - case 'contains': - return attrVal.includes(rule.value); - case 'startsWith': - return attrVal.startsWith(rule.value); - case 'in': - return rule.value - .split(',') - .map((v) => v.trim()) - .includes(attrVal); - default: - return false; - } - }); - } - - default: - return false; - } -} +// – Core types – ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── \ No newline at end of file From 412dba2797db6b2a551e444496658a01cc823b70 Mon Sep 17 00:00:00 2001 From: Marvelous Samuel Date: Sat, 5 Sep 2026 10:06:48 +0100 Subject: [PATCH 3/6] fix(ci): resolve failing checks for #1312 --- src/app/pages/admin/feature-flags/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/pages/admin/feature-flags/page.tsx b/src/app/pages/admin/feature-flags/page.tsx index a129b581..ec2fe3b8 100644 --- a/src/app/pages/admin/feature-flags/page.tsx +++ b/src/app/pages/admin/feature-flags/page.tsx @@ -352,9 +352,9 @@ function AuditPanel({ flagId, onClose }: { flagId?: string; onClose: () => void setLoading(false); }, [flagId]); - useState(() => { + useEffect(() => { void load(); - }); + }, [load]); const ACTION_COLORS: Record = { created: 'green', From fd6b9b5f988a256d6352ea6d54a5e994ede7d16b Mon Sep 17 00:00:00 2001 From: Marvelous Samuel Date: Sat, 5 Sep 2026 10:06:49 +0100 Subject: [PATCH 4/6] fix(ci): resolve failing checks for #1312 --- src/app/api/admin/feature-flags/evaluate/route.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/api/admin/feature-flags/evaluate/route.ts b/src/app/api/admin/feature-flags/evaluate/route.ts index 2d8d7e41..991cef81 100644 --- a/src/app/api/admin/feature-flags/evaluate/route.ts +++ b/src/app/api/admin/feature-flags/evaluate/route.ts @@ -15,7 +15,8 @@ export async function GET(req: NextRequest) { const { addHeaders, rateLimitResponse } = withRateLimit(req, 'READ'); if (rateLimitResponse) return rateLimitResponse; - const { searchParams } = new URL(req.url); + const { searchParams } = new URL( req.url); + const id = searchParams.get('id'); if (!id) { @@ -33,6 +34,6 @@ export async function GET(req: NextRequest) { if (key !== 'id') context[key] = value; }); - const isEnabled = evaluateFlag(flag, context); + const isEnabled = await evaluateFlag(flag, context); return addHeaders(NextResponse.json({ flag, isEnabled, context })); } From cdeaf45bba8c28fa90a71c73454ef70d8a8692b1 Mon Sep 17 00:00:00 2001 From: Marvelous Samuel Date: Sat, 5 Sep 2026 10:06:50 +0100 Subject: [PATCH 5/6] fix(ci): resolve failing checks for #1312 From c1d6f08b5a1690bc319b5297e441109be8e5b873 Mon Sep 17 00:00:00 2001 From: Marvelous Samuel Date: Sat, 5 Sep 2026 10:06:51 +0100 Subject: [PATCH 6/6] fix(ci): resolve failing checks for #1312 --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34d1c10e..7aa15c12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,6 +167,8 @@ jobs: - name: Run Tests shell: bash + env: + NEXT_PUBLIC_STARKNET_NETWORK: goerli-alpha run: | if timeout 30s pnpm vitest run --coverage; then echo "Tests completed within the 30-second limit."