diff --git a/.env.example b/.env.example index 7e5e47991..d699a8e3f 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,9 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev ### Billing API URL (Required if NEXT_PUBLIC_INCLUDE_BILLING=1) # BILLING_API_URL=https://billing.e2b.dev +### LaunchDarkly server-side SDK key for server-evaluated feature flags +# LAUNCHDARKLY_SDK_KEY= + ### Vercel URL (automatically set in Vercel deployments) # VERCEL_URL= diff --git a/bun.lock b/bun.lock index b665a0e7a..9bc22d5d3 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "@e2b/dashboard", "dependencies": { "@hookform/resolvers": "^5.2.2", + "@launchdarkly/node-server-sdk": "^9.11.2", "@marsidev/react-turnstile": "^1.4.1", "@next-safe-action/adapter-react-hook-form": "^2.0.0", "@next/env": "^16.2.7", @@ -388,6 +389,12 @@ "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], + "@launchdarkly/js-sdk-common": ["@launchdarkly/js-sdk-common@2.25.1", "", {}, "sha512-erG2RbA8QQMKW+D9Y7Uahez1dU+TzmyTzv9qlB7b0Pr/DS2SmUg5H8pJbkLcRrSxoshczZdB1UqsuE3RAk7UYQ=="], + + "@launchdarkly/js-server-sdk-common": ["@launchdarkly/js-server-sdk-common@2.19.1", "", { "dependencies": { "@launchdarkly/js-sdk-common": "2.25.1", "semver": "7.5.4" } }, "sha512-3N1R62FF5qeBJdZbZk62ixGFG73kbwwqoHD00e0OygMB3hIRuV+spYbB3S8BXZ8VpnzKBame0OChC8lYrb/Zow=="], + + "@launchdarkly/node-server-sdk": ["@launchdarkly/node-server-sdk@9.11.2", "", { "dependencies": { "@launchdarkly/js-server-sdk-common": "2.19.1", "https-proxy-agent": "^7.0.6", "launchdarkly-eventsource": "2.2.0" } }, "sha512-+S0V8bkxSvD3THaLTw/AKG6OVFFUqcoKXDrV7JhuCk9xhvI/LsvgpdzHZdzJjlicW/U+DRn3rPvhzGdYPLyasg=="], + "@marsidev/react-turnstile": ["@marsidev/react-turnstile@1.4.1", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-1jE0IjvB8z+q1NFRs3149gXzXwIzXQWqQjn9fmAr13BiE3RYLWck5Me6flHYE90shW5L12Jkm6R1peS1OnA9oQ=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], @@ -1420,6 +1427,8 @@ "knip": ["knip@6.11.0", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-parser": "^0.128.0", "oxc-resolver": "^11.19.1", "picomatch": "^4.0.4", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.16", "unbash": "^3.0.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-84PTlN8Q5smLpTbzs8smTVh8PMbTDXtw0tFksXq/m6auGFC/KSzJykKFmnYh3As38kiWDkoDBvdTTyKk5M1TAQ=="], + "launchdarkly-eventsource": ["launchdarkly-eventsource@2.2.0", "", {}, "sha512-u38fYlLSq/m6oFz0MS1/76Sj2xzlYhTKZ+sf/vju6PA86PMc6fPlY5k8CdU79edLXjNwsvIQTDvDNy3llDqB8A=="], + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], @@ -1944,6 +1953,8 @@ "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@launchdarkly/js-server-sdk-common/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], + "@opentelemetry/auto-instrumentations-node/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], "@opentelemetry/auto-instrumentations-node/@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/configuration": "0.219.0", "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.219.0", "@opentelemetry/exporter-logs-otlp-http": "0.219.0", "@opentelemetry/exporter-logs-otlp-proto": "0.219.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.219.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.219.0", "@opentelemetry/exporter-prometheus": "0.219.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.219.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/exporter-trace-otlp-proto": "0.219.0", "@opentelemetry/exporter-zipkin": "2.8.0", "@opentelemetry/instrumentation": "0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/propagator-b3": "2.8.0", "@opentelemetry/propagator-jaeger": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/sdk-trace-node": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NWLpWLEb8gV3+JBHYoIrktbM385wyHpRJoh3J/4Q52d4PR+AlPMNGJT3DzBUrDSUEVbKAXoHR+EDAPxtiNcj8g=="], @@ -2378,6 +2389,8 @@ "@jest/types/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "@launchdarkly/js-server-sdk-common/semver/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "@opentelemetry/auto-instrumentations-node/@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], "@opentelemetry/auto-instrumentations-node/@opentelemetry/sdk-node/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="], @@ -2574,6 +2587,8 @@ "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@launchdarkly/js-server-sdk-common/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "@opentelemetry/auto-instrumentations-node/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], "@opentelemetry/auto-instrumentations-node/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], diff --git a/package.json b/package.json index 5a9d2d5a4..122836b80 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.2", + "@launchdarkly/node-server-sdk": "^9.11.2", "@marsidev/react-turnstile": "^1.4.1", "@next-safe-action/adapter-react-hook-form": "^2.0.0", "@next/env": "^16.2.7", diff --git a/src/app/dashboard/[teamSlug]/flags/page.tsx b/src/app/dashboard/[teamSlug]/flags/page.tsx new file mode 100644 index 000000000..b517b24db --- /dev/null +++ b/src/app/dashboard/[teamSlug]/flags/page.tsx @@ -0,0 +1,50 @@ +import { notFound, redirect } from 'next/navigation' +import { AUTH_URLS } from '@/configs/urls' +import { auth } from '@/core/server/auth' +import { listFeatureFlags } from '@/core/server/feature-flags/list.server' +import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' +import { FeatureFlagsTable } from '@/features/dashboard/flags/feature-flags' +import { Page } from '@/features/dashboard/layouts/page' + +interface FlagsPageProps { + params: Promise<{ + teamSlug: string + }> +} + +export default async function FlagsPage({ params }: FlagsPageProps) { + const [{ teamSlug }, authContext] = await Promise.all([ + params, + auth.getAuthContext(), + ]) + + if (!authContext) { + redirect(AUTH_URLS.SIGN_IN) + } + + const teamIdResult = await getTeamIdFromSlug( + teamSlug, + authContext.accessToken + ) + + if (!teamIdResult.ok || !teamIdResult.data) { + notFound() + } + + const context = { + userId: authContext.user.id, + teamId: teamIdResult.data, + } + const flags = await listFeatureFlags(context) + const isAdmin = flags.some((flag) => flag.id === 'isAdmin' && flag.value) + + if (!isAdmin) { + notFound() + } + + return ( + + + + ) +} diff --git a/src/configs/flags.ts b/src/configs/flags.ts index e9cbf269a..1569c6af2 100644 --- a/src/configs/flags.ts +++ b/src/configs/flags.ts @@ -1,3 +1,5 @@ +import type { z } from 'zod' + export const ALLOW_SEO_INDEXING = process.env.ALLOW_SEO_INDEXING === '1' export const VERBOSE = process.env.NEXT_PUBLIC_VERBOSE === '1' export const ENABLE_USER_BOOTSTRAP = process.env.ENABLE_USER_BOOTSTRAP === '1' @@ -38,3 +40,37 @@ export function isAuthMigrationInProgress() { } export const AUTH_MIGRATION_IN_PROGRESS = isAuthMigrationInProgress() + +export type BooleanFeatureFlagDefinition = { + kind: 'boolean' + key: string + defaultValue: boolean + description?: string +} + +export type JsonFeatureFlagDefinition = { + kind: 'json' + key: string + defaultValue: T + schema: z.ZodType + description?: string +} + +export type FeatureFlagDefinition = + | BooleanFeatureFlagDefinition + | JsonFeatureFlagDefinition + +export const FEATURE_FLAGS = { + isAdmin: { + kind: 'boolean', + key: 'is_admin', + defaultValue: false, + description: 'Enables dashboard admin-only surfaces.', + }, + iExist: { + kind: 'boolean', + key: 'i_exist', + defaultValue: false, + description: 'Test flag for validating LaunchDarkly team targeting.', + }, +} as const satisfies Record diff --git a/src/core/server/feature-flags/context.ts b/src/core/server/feature-flags/context.ts new file mode 100644 index 000000000..d01172752 --- /dev/null +++ b/src/core/server/feature-flags/context.ts @@ -0,0 +1,4 @@ +export type FeatureFlagContextInput = { + userId: string + teamId?: string +} diff --git a/src/core/server/feature-flags/flags.server.ts b/src/core/server/feature-flags/flags.server.ts new file mode 100644 index 000000000..98e3f7347 --- /dev/null +++ b/src/core/server/feature-flags/flags.server.ts @@ -0,0 +1,62 @@ +import 'server-only' + +import type { + BooleanFeatureFlagDefinition, + JsonFeatureFlagDefinition, +} from '@/configs/flags' +import type { FeatureFlagContextInput } from '@/core/server/feature-flags/context' +import { launchDarklyFeatureFlagProvider } from '@/core/server/feature-flags/launchdarkly' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' + +export type FeatureFlagProvider = { + getBoolean( + flag: BooleanFeatureFlagDefinition, + context: FeatureFlagContextInput + ): Promise + getJson( + flag: JsonFeatureFlagDefinition, + context: FeatureFlagContextInput + ): Promise +} + +export type FeatureFlagService = { + getBoolean( + flag: BooleanFeatureFlagDefinition, + context: FeatureFlagContextInput + ): Promise + getJson( + flag: JsonFeatureFlagDefinition, + context: FeatureFlagContextInput + ): Promise +} + +export function createFeatureFlagService( + provider: FeatureFlagProvider = launchDarklyFeatureFlagProvider +): FeatureFlagService { + return { + async getBoolean(flag, context) { + return provider.getBoolean(flag, context) + }, + async getJson(flag, context) { + const value = await provider.getJson(flag, context) + const parsed = flag.schema.safeParse(value) + + if (!parsed.success) { + l.warn( + { + key: 'feature_flags:invalid_json_flag', + context: { flagKey: flag.key }, + error: serializeErrorForLog(parsed.error), + }, + 'Feature flag JSON value has invalid shape' + ) + + return flag.defaultValue + } + + return parsed.data + }, + } +} + +export const featureFlags = createFeatureFlagService() diff --git a/src/core/server/feature-flags/launchdarkly.ts b/src/core/server/feature-flags/launchdarkly.ts new file mode 100644 index 000000000..6c95a373b --- /dev/null +++ b/src/core/server/feature-flags/launchdarkly.ts @@ -0,0 +1,167 @@ +import 'server-only' + +import { + basicLogger, + init, + type LDClient, + type LDContext, +} from '@launchdarkly/node-server-sdk' +import type { + BooleanFeatureFlagDefinition, + JsonFeatureFlagDefinition, +} from '@/configs/flags' +import type { FeatureFlagContextInput } from '@/core/server/feature-flags/context' +import type { FeatureFlagProvider } from '@/core/server/feature-flags/flags.server' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' + +const INITIALIZATION_TIMEOUT_SECONDS = 5 + +let launchDarklyClient: LDClient | undefined +let launchDarklyInitialization: Promise | undefined +let launchDarklyReady = false + +function getLaunchDarklySdkKey() { + const key = process.env.LAUNCHDARKLY_SDK_KEY?.trim() + return key || null +} + +function getLaunchDarklyClient() { + const sdkKey = getLaunchDarklySdkKey() + + if (!sdkKey) { + return null + } + + if (!launchDarklyClient) { + launchDarklyClient = init(sdkKey, { + logger: basicLogger({ level: 'warn' }), + }) + launchDarklyReady = false + launchDarklyInitialization = undefined + } + + return launchDarklyClient +} + +async function getInitializedLaunchDarklyClient() { + const client = getLaunchDarklyClient() + + if (!client) { + return null + } + + if (launchDarklyReady) { + return client + } + + launchDarklyInitialization ??= client + .waitForInitialization({ + timeout: INITIALIZATION_TIMEOUT_SECONDS, + }) + .then(() => { + launchDarklyReady = true + return client + }) + .catch((error: unknown) => { + launchDarklyInitialization = undefined + + l.warn( + { + key: 'launchdarkly:initialization_failed', + error: serializeErrorForLog(error), + }, + 'LaunchDarkly initialization failed' + ) + + return null + }) + + return launchDarklyInitialization +} + +export function createLaunchDarklyContext({ + userId, + teamId, +}: FeatureFlagContextInput): LDContext { + if (!teamId) { + return { + kind: 'user', + key: userId, + } + } + + return { + kind: 'multi', + user: { + key: userId, + }, + team: { + key: teamId, + }, + } +} + +async function getBooleanVariation( + flag: BooleanFeatureFlagDefinition, + context: FeatureFlagContextInput +) { + const client = await getInitializedLaunchDarklyClient() + + if (!client) { + return flag.defaultValue + } + + try { + return await client.boolVariation( + flag.key, + createLaunchDarklyContext(context), + flag.defaultValue + ) + } catch (error) { + l.warn( + { + key: 'launchdarkly:boolean_evaluation_failed', + context: { flagKey: flag.key }, + error: serializeErrorForLog(error), + }, + 'LaunchDarkly boolean flag evaluation failed' + ) + + return flag.defaultValue + } +} + +async function getJsonVariation( + flag: JsonFeatureFlagDefinition, + context: FeatureFlagContextInput +) { + const client = await getInitializedLaunchDarklyClient() + + if (!client) { + return flag.defaultValue + } + + try { + return await client.jsonVariation( + flag.key, + createLaunchDarklyContext(context), + flag.defaultValue + ) + } catch (error) { + l.warn( + { + key: 'launchdarkly:json_evaluation_failed', + context: { flagKey: flag.key }, + error: serializeErrorForLog(error), + }, + 'LaunchDarkly JSON flag evaluation failed' + ) + + return flag.defaultValue + } +} + +export const launchDarklyFeatureFlagProvider: FeatureFlagProvider = { + getBoolean: getBooleanVariation, + getJson: getJsonVariation, +} diff --git a/src/core/server/feature-flags/list.server.ts b/src/core/server/feature-flags/list.server.ts new file mode 100644 index 000000000..3649c24e2 --- /dev/null +++ b/src/core/server/feature-flags/list.server.ts @@ -0,0 +1,52 @@ +import 'server-only' + +import { + FEATURE_FLAGS, + type FeatureFlagDefinition, + type JsonFeatureFlagDefinition, +} from '@/configs/flags' +import type { FeatureFlagContextInput } from '@/core/server/feature-flags/context' +import { featureFlags } from '@/core/server/feature-flags/flags.server' + +export type EvaluatedFeatureFlag = { + id: string + key: string + kind: FeatureFlagDefinition['kind'] + description?: string + value: unknown + defaultValue: unknown +} + +async function evaluateFeatureFlag( + id: string, + flag: FeatureFlagDefinition, + context: FeatureFlagContextInput +): Promise { + const value = + flag.kind === 'boolean' + ? await featureFlags.getBoolean(flag, context) + : await featureFlags.getJson( + flag as JsonFeatureFlagDefinition, + context + ) + + return { + id, + key: flag.key, + kind: flag.kind, + description: flag.description, + value, + defaultValue: flag.defaultValue, + } +} + +export async function listFeatureFlags(context: FeatureFlagContextInput) { + const flags = Object.entries(FEATURE_FLAGS) as [ + string, + FeatureFlagDefinition, + ][] + + return Promise.all( + flags.map(([id, flag]) => evaluateFeatureFlag(id, flag, context)) + ) +} diff --git a/src/features/dashboard/flags/feature-flags.tsx b/src/features/dashboard/flags/feature-flags.tsx new file mode 100644 index 000000000..2f9a09e51 --- /dev/null +++ b/src/features/dashboard/flags/feature-flags.tsx @@ -0,0 +1,93 @@ +import type { EvaluatedFeatureFlag } from '@/core/server/feature-flags/list.server' +import { Badge } from '@/ui/primitives/badge' +import { + Table, + TableBody, + TableCell, + TableEmptyState, + TableHead, + TableHeader, + TableRow, +} from '@/ui/primitives/table' + +function formatFlagValue(value: unknown) { + if (typeof value === 'boolean') { + return value ? 'Enabled' : 'Disabled' + } + + return JSON.stringify(value) +} + +function FlagValueBadge({ value }: { value: unknown }) { + if (typeof value !== 'boolean') { + return {formatFlagValue(value)} + } + + return ( + + {formatFlagValue(value)} + + ) +} + +export function FeatureFlagsTable({ + flags, + teamId, +}: { + flags: EvaluatedFeatureFlag[] + teamId: string +}) { + return ( + + + Feature Flags + + Feature flags evaluated for this team. + + + team_id: {teamId} + + + + + + + Flag + LD Key + Value + Description + + + + {flags.length === 0 ? ( + + No feature flags configured + + ) : ( + flags.map((flag) => ( + + + + {flag.id} + + {flag.kind} + + + + + {flag.key} + + + + + + {flag.description ?? '-'} + + + )) + )} + + + + ) +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 84b5e8b10..290093f37 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -10,6 +10,7 @@ export const serverSchema = z.object({ BILLING_API_URL: z.url().optional(), ZEROBOUNCE_API_KEY: z.string().optional(), PLAIN_API_KEY: z.string().min(1).optional(), + LAUNCHDARKLY_SDK_KEY: z.string().min(1).optional(), TURNSTILE_SECRET_KEY: z.string().optional(), diff --git a/tests/unit/feature-flags.test.ts b/tests/unit/feature-flags.test.ts new file mode 100644 index 000000000..becbbfc83 --- /dev/null +++ b/tests/unit/feature-flags.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import type { + BooleanFeatureFlagDefinition, + JsonFeatureFlagDefinition, +} from '@/configs/flags' +import { createFeatureFlagService } from '@/core/server/feature-flags/flags.server' +import { createLaunchDarklyContext } from '@/core/server/feature-flags/launchdarkly' + +const context = { + userId: 'user-id', + teamId: 'team-id', +} + +describe('createFeatureFlagService', () => { + it('returns boolean values from the provider', async () => { + const flag = { + kind: 'boolean', + key: 'test-boolean', + defaultValue: false, + } satisfies BooleanFeatureFlagDefinition + const provider = { + getBoolean: vi.fn().mockResolvedValue(true), + getJson: vi.fn(), + } + + const result = await createFeatureFlagService(provider).getBoolean( + flag, + context + ) + + expect(result).toBe(true) + expect(provider.getBoolean).toHaveBeenCalledWith(flag, context) + }) + + it('parses JSON flag values through the flag definition schema', async () => { + const flag = { + kind: 'json', + key: 'test-json', + defaultValue: [], + schema: z.array(z.object({ name: z.string() })), + } satisfies JsonFeatureFlagDefinition<{ name: string }[]> + const provider = { + getBoolean: vi.fn(), + getJson: vi.fn().mockResolvedValue([{ name: 'Codex' }]), + } + + const result = await createFeatureFlagService(provider).getJson( + flag, + context + ) + + expect(result).toEqual([{ name: 'Codex' }]) + expect(provider.getJson).toHaveBeenCalledWith(flag, context) + }) + + it('returns the JSON fallback when provider data is invalid', async () => { + const flag = { + kind: 'json', + key: 'test-json', + defaultValue: [{ name: 'Fallback' }], + schema: z.array(z.object({ name: z.string() })), + } satisfies JsonFeatureFlagDefinition<{ name: string }[]> + const provider = { + getBoolean: vi.fn(), + getJson: vi.fn().mockResolvedValue([{ name: 123 }]), + } + + const result = await createFeatureFlagService(provider).getJson( + flag, + context + ) + + expect(result).toEqual([{ name: 'Fallback' }]) + }) +}) + +describe('createLaunchDarklyContext', () => { + it('targets teams by stable team id', () => { + expect(createLaunchDarklyContext(context)).toEqual({ + kind: 'multi', + user: { + key: 'user-id', + }, + team: { + key: 'team-id', + }, + }) + }) + + it('falls back to a user context when no team id is supplied', () => { + expect(createLaunchDarklyContext({ userId: 'user-id' })).toEqual({ + kind: 'user', + key: 'user-id', + }) + }) +})
+ Feature flags evaluated for this team. +