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." 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 })); } 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', 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'; diff --git a/src/lib/feature-flags/store.ts b/src/lib/feature-flags/store.ts index 33fd3119..7c0ea15f 100644 --- a/src/lib/feature-flags/store.ts +++ b/src/lib/feature-flags/store.ts @@ -108,6 +108,20 @@ export function generateId(prefix = ''): string { return prefix ? `${prefix}_${uuid}` : uuid; } +/** + * Deterministic 0–99 bucket for a flag/user pair. Used for percentage + * rollouts so the same user always lands in the same bucket. + */ +export function getPercentageBucket(flagId: string, userId = ''): number { + let hash = 0; + const key = flagId + userId; + for (let i = 0; i < key.length; i++) { + hash = Math.imul(31, hash) + key.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash) % 100; +} + export function createAuditEntry( action: AuditEntry['action'], actor: string, @@ -145,13 +159,7 @@ export function evaluateFlag(flag: FeatureFlag, context: Record 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; + const bucket = getPercentageBucket(flag.id, context.userId ?? ''); return bucket < flag.percentage; }