From 2106b1086ff1f786620736f00f494b5edfe0afbd Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Fri, 12 Jun 2026 16:26:07 -0700 Subject: [PATCH 01/13] Add LaunchDarkly feature flag provider --- .env.example | 3 + bun.lock | 15 ++ package.json | 1 + src/configs/flags.ts | 17 ++ src/core/server/api/middlewares/repository.ts | 11 +- src/core/server/feature-flags/context.ts | 5 + src/core/server/feature-flags/flags.server.ts | 62 ++++++ src/core/server/feature-flags/launchdarkly.ts | 179 ++++++++++++++++++ src/core/shared/repository-scope.ts | 1 + src/lib/env.ts | 1 + tests/unit/feature-flags.test.ts | 75 ++++++++ 11 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 src/core/server/feature-flags/context.ts create mode 100644 src/core/server/feature-flags/flags.server.ts create mode 100644 src/core/server/feature-flags/launchdarkly.ts create mode 100644 tests/unit/feature-flags.test.ts 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/configs/flags.ts b/src/configs/flags.ts index e9cbf269a..b81fdbf04 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,18 @@ export function isAuthMigrationInProgress() { } export const AUTH_MIGRATION_IN_PROGRESS = isAuthMigrationInProgress() + +export type BooleanFeatureFlagDefinition = { + kind: 'boolean' + key: string + defaultValue: boolean +} + +export type JsonFeatureFlagDefinition = { + kind: 'json' + key: string + defaultValue: T + schema: z.ZodType +} + +export const FEATURE_FLAGS = {} as const diff --git a/src/core/server/api/middlewares/repository.ts b/src/core/server/api/middlewares/repository.ts index 5d1b7b073..71b6ecc5c 100644 --- a/src/core/server/api/middlewares/repository.ts +++ b/src/core/server/api/middlewares/repository.ts @@ -46,7 +46,7 @@ export function withTeamAuthedRequestRepository< createRepository: (scope: TeamRequestScope) => TRepository, extendContext: (repository: TRepository) => TContextExtension ) { - return t.middleware(({ ctx, next }) => { + return t.middleware(({ ctx, next, input }) => { if (!ctx.session) { throw unauthorizedUserError() } @@ -59,9 +59,18 @@ export function withTeamAuthedRequestRepository< throw forbiddenTeamAccessError() } + const teamSlug = + input && + typeof input === 'object' && + 'teamSlug' in input && + typeof input.teamSlug === 'string' + ? input.teamSlug + : undefined + const repository = createRepository({ accessToken: ctx.session.access_token, teamId: ctx.teamId, + teamSlug, }) return next({ diff --git a/src/core/server/feature-flags/context.ts b/src/core/server/feature-flags/context.ts new file mode 100644 index 000000000..c5c4db660 --- /dev/null +++ b/src/core/server/feature-flags/context.ts @@ -0,0 +1,5 @@ +export type FeatureFlagContextInput = { + userId: string + teamId?: string + teamSlug?: 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..7063ca275 --- /dev/null +++ b/src/core/server/feature-flags/launchdarkly.ts @@ -0,0 +1,179 @@ +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 + +type LaunchDarklyGlobal = typeof globalThis & { + __dashboardLaunchDarklyClient?: LDClient + __dashboardLaunchDarklyInitialization?: Promise + __dashboardLaunchDarklyReady?: boolean +} + +function getLaunchDarklyGlobal() { + return globalThis as LaunchDarklyGlobal +} + +function getLaunchDarklySdkKey() { + const key = process.env.LAUNCHDARKLY_SDK_KEY?.trim() + return key || null +} + +function getLaunchDarklyClient() { + const sdkKey = getLaunchDarklySdkKey() + + if (!sdkKey) { + return null + } + + const ldGlobal = getLaunchDarklyGlobal() + + if (!ldGlobal.__dashboardLaunchDarklyClient) { + ldGlobal.__dashboardLaunchDarklyClient = init(sdkKey, { + logger: basicLogger({ level: 'warn' }), + }) + ldGlobal.__dashboardLaunchDarklyReady = false + ldGlobal.__dashboardLaunchDarklyInitialization = undefined + } + + return ldGlobal.__dashboardLaunchDarklyClient +} + +async function getInitializedLaunchDarklyClient() { + const client = getLaunchDarklyClient() + + if (!client) { + return null + } + + const ldGlobal = getLaunchDarklyGlobal() + + if (ldGlobal.__dashboardLaunchDarklyReady) { + return client + } + + ldGlobal.__dashboardLaunchDarklyInitialization ??= client + .waitForInitialization({ + timeout: INITIALIZATION_TIMEOUT_SECONDS, + }) + .then(() => { + ldGlobal.__dashboardLaunchDarklyReady = true + return client + }) + .catch((error: unknown) => { + ldGlobal.__dashboardLaunchDarklyInitialization = undefined + + l.warn( + { + key: 'launchdarkly:initialization_failed', + error: serializeErrorForLog(error), + }, + 'LaunchDarkly initialization failed' + ) + + return null + }) + + return ldGlobal.__dashboardLaunchDarklyInitialization +} + +function createLaunchDarklyContext({ + userId, + teamId, + teamSlug, +}: FeatureFlagContextInput): LDContext { + if (!teamId) { + return { + kind: 'user', + key: userId, + } + } + + return { + kind: 'multi', + user: { + key: userId, + }, + team: { + key: teamId, + ...(teamSlug ? { slug: teamSlug } : {}), + }, + } +} + +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/shared/repository-scope.ts b/src/core/shared/repository-scope.ts index 03af7669e..cc1e45da5 100644 --- a/src/core/shared/repository-scope.ts +++ b/src/core/shared/repository-scope.ts @@ -4,4 +4,5 @@ export interface RequestScope { export interface TeamRequestScope extends RequestScope { teamId: string + teamSlug?: string } 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..b42337078 --- /dev/null +++ b/tests/unit/feature-flags.test.ts @@ -0,0 +1,75 @@ +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' + +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' }]) + }) +}) From 67231da4fa42242ab433953692cbe2ef727f0876 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Fri, 12 Jun 2026 17:35:41 -0700 Subject: [PATCH 02/13] Add dashboard feature flag evaluations --- src/configs/flags.ts | 21 +++++++- .../dashboard-features.server.ts | 14 +++++ src/core/server/feature-flags/list.server.ts | 52 +++++++++++++++++++ src/features/dashboard/features.ts | 9 ++++ 4 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 src/core/server/feature-flags/dashboard-features.server.ts create mode 100644 src/core/server/feature-flags/list.server.ts create mode 100644 src/features/dashboard/features.ts diff --git a/src/configs/flags.ts b/src/configs/flags.ts index b81fdbf04..1569c6af2 100644 --- a/src/configs/flags.ts +++ b/src/configs/flags.ts @@ -45,6 +45,7 @@ export type BooleanFeatureFlagDefinition = { kind: 'boolean' key: string defaultValue: boolean + description?: string } export type JsonFeatureFlagDefinition = { @@ -52,6 +53,24 @@ export type JsonFeatureFlagDefinition = { key: string defaultValue: T schema: z.ZodType + description?: string } -export const FEATURE_FLAGS = {} as const +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/dashboard-features.server.ts b/src/core/server/feature-flags/dashboard-features.server.ts new file mode 100644 index 000000000..cead995a1 --- /dev/null +++ b/src/core/server/feature-flags/dashboard-features.server.ts @@ -0,0 +1,14 @@ +import 'server-only' + +import { FEATURE_FLAGS } from '@/configs/flags' +import type { FeatureFlagContextInput } from '@/core/server/feature-flags/context' +import { featureFlags } from '@/core/server/feature-flags/flags.server' +import type { DashboardFeatures } from '@/features/dashboard/features' + +export async function getDashboardFeatures( + context: FeatureFlagContextInput +): Promise { + return { + isAdmin: await featureFlags.getBoolean(FEATURE_FLAGS.isAdmin, context), + } +} 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/features.ts b/src/features/dashboard/features.ts new file mode 100644 index 000000000..29a1956ef --- /dev/null +++ b/src/features/dashboard/features.ts @@ -0,0 +1,9 @@ +export type DashboardFeatures = { + isAdmin: boolean +} + +export const DEFAULT_DASHBOARD_FEATURES = { + isAdmin: false, +} satisfies DashboardFeatures + +export type DashboardFeatureKey = keyof DashboardFeatures From e95da9eebf3d70ea4e37d25dccee070743311030 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Fri, 12 Jun 2026 17:35:52 -0700 Subject: [PATCH 03/13] Add admin feature flags page --- src/app/dashboard/[teamSlug]/admin/page.tsx | 57 +++++++++++ src/configs/urls.ts | 1 + .../dashboard/admin/feature-flags.tsx | 96 +++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 src/app/dashboard/[teamSlug]/admin/page.tsx create mode 100644 src/features/dashboard/admin/feature-flags.tsx diff --git a/src/app/dashboard/[teamSlug]/admin/page.tsx b/src/app/dashboard/[teamSlug]/admin/page.tsx new file mode 100644 index 000000000..fc68eb842 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/admin/page.tsx @@ -0,0 +1,57 @@ +import { notFound, redirect } from 'next/navigation' +import { AUTH_URLS } from '@/configs/urls' +import { auth } from '@/core/server/auth' +import { getDashboardFeatures } from '@/core/server/feature-flags/dashboard-features.server' +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/admin/feature-flags' +import { Page } from '@/features/dashboard/layouts/page' + +interface AdminPageProps { + params: Promise<{ + teamSlug: string + }> +} + +export default async function AdminPage({ params }: AdminPageProps) { + 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, + teamSlug, + } + const features = await getDashboardFeatures(context) + + if (!features.isAdmin) { + notFound() + } + + const flags = await listFeatureFlags(context) + + return ( + + + + ) +} diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 9412b2a97..e88a61717 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -20,6 +20,7 @@ export const PROTECTED_URLS = { GENERAL: (teamSlug: string) => `/dashboard/${teamSlug}/general`, KEYS: (teamSlug: string) => `/dashboard/${teamSlug}/keys`, MEMBERS: (teamSlug: string) => `/dashboard/${teamSlug}/members`, + ADMIN: (teamSlug: string) => `/dashboard/${teamSlug}/admin`, SANDBOXES: (teamSlug: string) => `/dashboard/${teamSlug}/sandboxes/monitoring`, diff --git a/src/features/dashboard/admin/feature-flags.tsx b/src/features/dashboard/admin/feature-flags.tsx new file mode 100644 index 000000000..6f7339b35 --- /dev/null +++ b/src/features/dashboard/admin/feature-flags.tsx @@ -0,0 +1,96 @@ +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, + teamSlug, +}: { + flags: EvaluatedFeatureFlag[] + teamId: string + teamSlug: string +}) { + return ( +
+
+

Admin

+

+ Feature flags evaluated for this team. +

+
+ team: {teamSlug} + 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 ?? '-'} + +
+ )) + )} +
+
+
+ ) +} From 6829ca65ca481b9c6ac6a9c99768828406abe046 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Fri, 12 Jun 2026 17:36:05 -0700 Subject: [PATCH 04/13] Gate dashboard navigation by feature flags --- src/app/dashboard/[teamSlug]/layout.tsx | 47 +++++++++++++++------- src/app/dashboard/[teamSlug]/team-gate.tsx | 4 ++ src/configs/sidebar.ts | 25 +++++++++++- src/features/dashboard/context.tsx | 5 +++ src/features/dashboard/sidebar/command.tsx | 12 ++++-- src/features/dashboard/sidebar/content.tsx | 15 ++++--- 6 files changed, 83 insertions(+), 25 deletions(-) diff --git a/src/app/dashboard/[teamSlug]/layout.tsx b/src/app/dashboard/[teamSlug]/layout.tsx index 44005d357..1f645eff4 100644 --- a/src/app/dashboard/[teamSlug]/layout.tsx +++ b/src/app/dashboard/[teamSlug]/layout.tsx @@ -9,6 +9,9 @@ import { AUTH_URLS } from '@/configs/urls' import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' import { DASHBOARD_USER_PROFILE_QUERY_OPTIONS } from '@/core/application/user/queries' import { auth } from '@/core/server/auth' +import { getDashboardFeatures } from '@/core/server/feature-flags/dashboard-features.server' +import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' +import { DEFAULT_DASHBOARD_FEATURES } from '@/features/dashboard/features' import DashboardLayoutView from '@/features/dashboard/layouts/layout' import Sidebar from '@/features/dashboard/sidebar/sidebar' import { OryPostHogIdentityBridge } from '@/features/ory-posthog-identity-bridge' @@ -47,25 +50,39 @@ export default async function DashboardLayout({ throw redirect(AUTH_URLS.SIGN_IN) } - await Promise.all([ - prefetchAsync( - trpc.teams.list.queryOptions( - undefined, - DASHBOARD_TEAMS_LIST_QUERY_OPTIONS - ) - ), - prefetchAsync( - trpc.user.profile.queryOptions( - undefined, - DASHBOARD_USER_PROFILE_QUERY_OPTIONS - ) - ), - ]) + const teamsPrefetch = prefetchAsync( + trpc.teams.list.queryOptions(undefined, DASHBOARD_TEAMS_LIST_QUERY_OPTIONS) + ) + const userProfilePrefetch = prefetchAsync( + trpc.user.profile.queryOptions( + undefined, + DASHBOARD_USER_PROFILE_QUERY_OPTIONS + ) + ) + + const teamIdResult = await getTeamIdFromSlug( + teamSlug, + authContext.accessToken + ) + const features = + teamIdResult.ok && teamIdResult.data + ? await getDashboardFeatures({ + userId: authContext.user.id, + teamId: teamIdResult.data, + teamSlug, + }) + : DEFAULT_DASHBOARD_FEATURES + + await Promise.all([teamsPrefetch, userProfilePrefetch]) return ( {postHogEnabled && } - + diff --git a/src/app/dashboard/[teamSlug]/team-gate.tsx b/src/app/dashboard/[teamSlug]/team-gate.tsx index ea918f4aa..1c3ffb590 100644 --- a/src/app/dashboard/[teamSlug]/team-gate.tsx +++ b/src/app/dashboard/[teamSlug]/team-gate.tsx @@ -5,18 +5,21 @@ import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/que import { DASHBOARD_USER_PROFILE_QUERY_OPTIONS } from '@/core/application/user/queries' import type { AuthUser } from '@/core/modules/auth/models' import { DashboardContextProvider } from '@/features/dashboard/context' +import type { DashboardFeatures } from '@/features/dashboard/features' import LoadingLayout from '@/features/dashboard/loading-layout' import { useTRPC } from '@/trpc/client' import Unauthorized from '../unauthorized' interface DashboardTeamGateProps { teamSlug: string + features: DashboardFeatures fallbackUser: AuthUser children: React.ReactNode } export function DashboardTeamGate({ teamSlug, + features, fallbackUser, children, }: DashboardTeamGateProps) { @@ -46,6 +49,7 @@ export function DashboardTeamGate({ return ( JSX.Element group?: string activeMatch?: string + requiredFeature?: DashboardFeatureKey } export const SIDEBAR_MAIN_LINKS: SidebarNavItem[] = [ @@ -78,6 +84,14 @@ export const SIDEBAR_MAIN_LINKS: SidebarNavItem[] = [ group: 'team', activeMatch: `/dashboard/*/members`, }, + { + label: 'Admin', + href: (args) => PROTECTED_URLS.ADMIN(args.teamSlug!), + icon: VaultIcon, + group: 'team', + activeMatch: `/dashboard/*/admin`, + requiredFeature: 'isAdmin', + }, // Billing ...(INCLUDE_BILLING @@ -117,3 +131,12 @@ export const SIDEBAR_EXTRA_LINKS: SidebarNavItem[] = [ ] export const SIDEBAR_ALL_LINKS = [...SIDEBAR_MAIN_LINKS, ...SIDEBAR_EXTRA_LINKS] + +export function getVisibleSidebarLinks( + links: T[], + features: DashboardFeatures +) { + return links.filter( + (link) => !link.requiredFeature || features[link.requiredFeature] + ) +} diff --git a/src/features/dashboard/context.tsx b/src/features/dashboard/context.tsx index 464cd51be..798dc3800 100644 --- a/src/features/dashboard/context.tsx +++ b/src/features/dashboard/context.tsx @@ -6,8 +6,10 @@ import { COOKIE_KEYS } from '@/configs/cookies' import type { AuthUser } from '@/core/modules/auth/models' import type { TeamModel } from '@/core/modules/teams/models' import { setBrowserCookie } from '@/lib/utils/browser-cookies' +import type { DashboardFeatures } from './features' interface DashboardContextValue { + features: DashboardFeatures team: TeamModel teams: TeamModel[] user: AuthUser @@ -19,6 +21,7 @@ const DashboardContext = createContext( interface DashboardContextProviderProps { children: ReactNode + features: DashboardContextValue['features'] initialTeam: TeamModel initialTeams: TeamModel[] initialUser: AuthUser @@ -26,6 +29,7 @@ interface DashboardContextProviderProps { export function DashboardContextProvider({ children, + features, initialTeam, initialTeams, initialUser, @@ -40,6 +44,7 @@ export function DashboardContextProvider({ }, [initialTeam, updateTeamCookieState]) const value = { + features, team: initialTeam, teams: initialTeams, user: initialUser, diff --git a/src/features/dashboard/sidebar/command.tsx b/src/features/dashboard/sidebar/command.tsx index 1ee3635d2..2090cfa52 100644 --- a/src/features/dashboard/sidebar/command.tsx +++ b/src/features/dashboard/sidebar/command.tsx @@ -1,8 +1,8 @@ 'use client' import { useRouter } from 'next/navigation' -import { useState } from 'react' -import { SIDEBAR_ALL_LINKS } from '@/configs/sidebar' +import { useMemo, useState } from 'react' +import { getVisibleSidebarLinks, SIDEBAR_ALL_LINKS } from '@/configs/sidebar' import useKeydown from '@/lib/hooks/use-keydown' import { cn } from '@/lib/utils' import { @@ -29,8 +29,12 @@ export default function DashboardSidebarCommand({ className, }: DashboardSidebarCommandProps) { const [open, setOpen] = useState(false) - const { team } = useDashboard() + const { features, team } = useDashboard() const router = useRouter() + const links = useMemo( + () => getVisibleSidebarLinks(SIDEBAR_ALL_LINKS, features), + [features] + ) const { open: sidebarOpen, openMobile: sidebarOpenMobile } = useSidebar() const isSidebarOpen = sidebarOpen || sidebarOpenMobile @@ -75,7 +79,7 @@ export default function DashboardSidebarCommand({ No results found. - {SIDEBAR_ALL_LINKS.map((link) => ( + {links.map((link) => ( { diff --git a/src/features/dashboard/sidebar/content.tsx b/src/features/dashboard/sidebar/content.tsx index 3fb87453f..e1d660d97 100644 --- a/src/features/dashboard/sidebar/content.tsx +++ b/src/features/dashboard/sidebar/content.tsx @@ -3,7 +3,11 @@ import micromatch from 'micromatch' import { usePathname } from 'next/navigation' import { useMemo } from 'react' -import { SIDEBAR_MAIN_LINKS, type SidebarNavItem } from '@/configs/sidebar' +import { + getVisibleSidebarLinks, + SIDEBAR_MAIN_LINKS, + type SidebarNavItem, +} from '@/configs/sidebar' import { useIsMobile } from '@/lib/hooks/use-mobile' import { cn } from '@/lib/utils' @@ -36,7 +40,7 @@ const createGroupedLinks = (links: SidebarNavItem[]): GroupedLinks => { } export default function DashboardSidebarContent() { - const { team } = useDashboard() + const { features, team } = useDashboard() const selectedTeamSlug = team.slug const pathname = usePathname() @@ -44,8 +48,9 @@ export default function DashboardSidebarContent() { const { setOpenMobile } = useSidebar() const groupedNavLinks = useMemo( - () => createGroupedLinks(SIDEBAR_MAIN_LINKS), - [] + () => + createGroupedLinks(getVisibleSidebarLinks(SIDEBAR_MAIN_LINKS, features)), + [features] ) const isActive = (link: SidebarNavItem) => { @@ -56,7 +61,7 @@ export default function DashboardSidebarContent() { return ( - {Object.entries(groupedNavLinks).map(([group, links], ix) => ( + {Object.entries(groupedNavLinks).map(([group, links]) => ( {group !== 'ungrouped' && ( {group} From 8ce1735691f5a0fd8ee0263f4ca95cb6dad92346 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Fri, 12 Jun 2026 17:41:18 -0700 Subject: [PATCH 05/13] Use team list for dashboard feature context --- src/app/dashboard/[teamSlug]/layout.tsx | 38 ++++++++++++++----------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/app/dashboard/[teamSlug]/layout.tsx b/src/app/dashboard/[teamSlug]/layout.tsx index 1f645eff4..73f52f18c 100644 --- a/src/app/dashboard/[teamSlug]/layout.tsx +++ b/src/app/dashboard/[teamSlug]/layout.tsx @@ -10,12 +10,16 @@ import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/que import { DASHBOARD_USER_PROFILE_QUERY_OPTIONS } from '@/core/application/user/queries' import { auth } from '@/core/server/auth' import { getDashboardFeatures } from '@/core/server/feature-flags/dashboard-features.server' -import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' import { DEFAULT_DASHBOARD_FEATURES } from '@/features/dashboard/features' import DashboardLayoutView from '@/features/dashboard/layouts/layout' import Sidebar from '@/features/dashboard/sidebar/sidebar' import { OryPostHogIdentityBridge } from '@/features/ory-posthog-identity-bridge' -import { HydrateClient, prefetchAsync, trpc } from '@/trpc/server' +import { + getQueryClient, + HydrateClient, + prefetchAsync, + trpc, +} from '@/trpc/server' import { SidebarInset, SidebarProvider } from '@/ui/primitives/sidebar' export const metadata: Metadata = { @@ -50,9 +54,12 @@ export default async function DashboardLayout({ throw redirect(AUTH_URLS.SIGN_IN) } - const teamsPrefetch = prefetchAsync( - trpc.teams.list.queryOptions(undefined, DASHBOARD_TEAMS_LIST_QUERY_OPTIONS) + const queryClient = getQueryClient() + const teamsQueryOptions = trpc.teams.list.queryOptions( + undefined, + DASHBOARD_TEAMS_LIST_QUERY_OPTIONS ) + const teamsPromise = queryClient.fetchQuery(teamsQueryOptions) const userProfilePrefetch = prefetchAsync( trpc.user.profile.queryOptions( undefined, @@ -60,20 +67,17 @@ export default async function DashboardLayout({ ) ) - const teamIdResult = await getTeamIdFromSlug( - teamSlug, - authContext.accessToken - ) - const features = - teamIdResult.ok && teamIdResult.data - ? await getDashboardFeatures({ - userId: authContext.user.id, - teamId: teamIdResult.data, - teamSlug, - }) - : DEFAULT_DASHBOARD_FEATURES + const teams = await teamsPromise + const team = teams.find((candidate) => candidate.slug === teamSlug) + const features = team?.id + ? await getDashboardFeatures({ + userId: authContext.user.id, + teamId: team.id, + teamSlug, + }) + : DEFAULT_DASHBOARD_FEATURES - await Promise.all([teamsPrefetch, userProfilePrefetch]) + await userProfilePrefetch return ( From 6235ea61922e9547b649ad077b015e8ae43a59a9 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Fri, 12 Jun 2026 17:41:28 -0700 Subject: [PATCH 06/13] Remove redundant sidebar link memoization --- src/features/dashboard/sidebar/command.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/features/dashboard/sidebar/command.tsx b/src/features/dashboard/sidebar/command.tsx index 2090cfa52..069362d7f 100644 --- a/src/features/dashboard/sidebar/command.tsx +++ b/src/features/dashboard/sidebar/command.tsx @@ -1,7 +1,7 @@ 'use client' import { useRouter } from 'next/navigation' -import { useMemo, useState } from 'react' +import { useState } from 'react' import { getVisibleSidebarLinks, SIDEBAR_ALL_LINKS } from '@/configs/sidebar' import useKeydown from '@/lib/hooks/use-keydown' import { cn } from '@/lib/utils' @@ -31,10 +31,7 @@ export default function DashboardSidebarCommand({ const [open, setOpen] = useState(false) const { features, team } = useDashboard() const router = useRouter() - const links = useMemo( - () => getVisibleSidebarLinks(SIDEBAR_ALL_LINKS, features), - [features] - ) + const links = getVisibleSidebarLinks(SIDEBAR_ALL_LINKS, features) const { open: sidebarOpen, openMobile: sidebarOpenMobile } = useSidebar() const isSidebarOpen = sidebarOpen || sidebarOpenMobile From 886464093787ff61c36947608813dabc7d300e62 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Fri, 12 Jun 2026 17:47:38 -0700 Subject: [PATCH 07/13] Keep admin flags off dashboard navigation --- src/app/dashboard/[teamSlug]/admin/page.tsx | 8 ++- src/app/dashboard/[teamSlug]/layout.tsx | 53 ++++++------------- src/app/dashboard/[teamSlug]/team-gate.tsx | 4 -- src/configs/sidebar.ts | 24 --------- .../dashboard-features.server.ts | 14 ----- src/features/dashboard/context.tsx | 5 -- src/features/dashboard/features.ts | 9 ---- src/features/dashboard/sidebar/command.tsx | 7 ++- src/features/dashboard/sidebar/content.tsx | 13 ++--- 9 files changed, 26 insertions(+), 111 deletions(-) delete mode 100644 src/core/server/feature-flags/dashboard-features.server.ts delete mode 100644 src/features/dashboard/features.ts diff --git a/src/app/dashboard/[teamSlug]/admin/page.tsx b/src/app/dashboard/[teamSlug]/admin/page.tsx index fc68eb842..457401a02 100644 --- a/src/app/dashboard/[teamSlug]/admin/page.tsx +++ b/src/app/dashboard/[teamSlug]/admin/page.tsx @@ -1,7 +1,6 @@ import { notFound, redirect } from 'next/navigation' import { AUTH_URLS } from '@/configs/urls' import { auth } from '@/core/server/auth' -import { getDashboardFeatures } from '@/core/server/feature-flags/dashboard-features.server' 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/admin/feature-flags' @@ -37,14 +36,13 @@ export default async function AdminPage({ params }: AdminPageProps) { teamId: teamIdResult.data, teamSlug, } - const features = await getDashboardFeatures(context) + const flags = await listFeatureFlags(context) + const isAdmin = flags.some((flag) => flag.id === 'isAdmin' && flag.value) - if (!features.isAdmin) { + if (!isAdmin) { notFound() } - const flags = await listFeatureFlags(context) - return ( candidate.slug === teamSlug) - const features = team?.id - ? await getDashboardFeatures({ - userId: authContext.user.id, - teamId: team.id, - teamSlug, - }) - : DEFAULT_DASHBOARD_FEATURES - - await userProfilePrefetch + await Promise.all([ + prefetchAsync( + trpc.teams.list.queryOptions( + undefined, + DASHBOARD_TEAMS_LIST_QUERY_OPTIONS + ) + ), + prefetchAsync( + trpc.user.profile.queryOptions( + undefined, + DASHBOARD_USER_PROFILE_QUERY_OPTIONS + ) + ), + ]) return ( {postHogEnabled && } - + diff --git a/src/app/dashboard/[teamSlug]/team-gate.tsx b/src/app/dashboard/[teamSlug]/team-gate.tsx index 1c3ffb590..ea918f4aa 100644 --- a/src/app/dashboard/[teamSlug]/team-gate.tsx +++ b/src/app/dashboard/[teamSlug]/team-gate.tsx @@ -5,21 +5,18 @@ import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/que import { DASHBOARD_USER_PROFILE_QUERY_OPTIONS } from '@/core/application/user/queries' import type { AuthUser } from '@/core/modules/auth/models' import { DashboardContextProvider } from '@/features/dashboard/context' -import type { DashboardFeatures } from '@/features/dashboard/features' import LoadingLayout from '@/features/dashboard/loading-layout' import { useTRPC } from '@/trpc/client' import Unauthorized from '../unauthorized' interface DashboardTeamGateProps { teamSlug: string - features: DashboardFeatures fallbackUser: AuthUser children: React.ReactNode } export function DashboardTeamGate({ teamSlug, - features, fallbackUser, children, }: DashboardTeamGateProps) { @@ -49,7 +46,6 @@ export function DashboardTeamGate({ return ( JSX.Element group?: string activeMatch?: string - requiredFeature?: DashboardFeatureKey } export const SIDEBAR_MAIN_LINKS: SidebarNavItem[] = [ @@ -84,15 +78,6 @@ export const SIDEBAR_MAIN_LINKS: SidebarNavItem[] = [ group: 'team', activeMatch: `/dashboard/*/members`, }, - { - label: 'Admin', - href: (args) => PROTECTED_URLS.ADMIN(args.teamSlug!), - icon: VaultIcon, - group: 'team', - activeMatch: `/dashboard/*/admin`, - requiredFeature: 'isAdmin', - }, - // Billing ...(INCLUDE_BILLING ? [ @@ -131,12 +116,3 @@ export const SIDEBAR_EXTRA_LINKS: SidebarNavItem[] = [ ] export const SIDEBAR_ALL_LINKS = [...SIDEBAR_MAIN_LINKS, ...SIDEBAR_EXTRA_LINKS] - -export function getVisibleSidebarLinks( - links: T[], - features: DashboardFeatures -) { - return links.filter( - (link) => !link.requiredFeature || features[link.requiredFeature] - ) -} diff --git a/src/core/server/feature-flags/dashboard-features.server.ts b/src/core/server/feature-flags/dashboard-features.server.ts deleted file mode 100644 index cead995a1..000000000 --- a/src/core/server/feature-flags/dashboard-features.server.ts +++ /dev/null @@ -1,14 +0,0 @@ -import 'server-only' - -import { FEATURE_FLAGS } from '@/configs/flags' -import type { FeatureFlagContextInput } from '@/core/server/feature-flags/context' -import { featureFlags } from '@/core/server/feature-flags/flags.server' -import type { DashboardFeatures } from '@/features/dashboard/features' - -export async function getDashboardFeatures( - context: FeatureFlagContextInput -): Promise { - return { - isAdmin: await featureFlags.getBoolean(FEATURE_FLAGS.isAdmin, context), - } -} diff --git a/src/features/dashboard/context.tsx b/src/features/dashboard/context.tsx index 798dc3800..464cd51be 100644 --- a/src/features/dashboard/context.tsx +++ b/src/features/dashboard/context.tsx @@ -6,10 +6,8 @@ import { COOKIE_KEYS } from '@/configs/cookies' import type { AuthUser } from '@/core/modules/auth/models' import type { TeamModel } from '@/core/modules/teams/models' import { setBrowserCookie } from '@/lib/utils/browser-cookies' -import type { DashboardFeatures } from './features' interface DashboardContextValue { - features: DashboardFeatures team: TeamModel teams: TeamModel[] user: AuthUser @@ -21,7 +19,6 @@ const DashboardContext = createContext( interface DashboardContextProviderProps { children: ReactNode - features: DashboardContextValue['features'] initialTeam: TeamModel initialTeams: TeamModel[] initialUser: AuthUser @@ -29,7 +26,6 @@ interface DashboardContextProviderProps { export function DashboardContextProvider({ children, - features, initialTeam, initialTeams, initialUser, @@ -44,7 +40,6 @@ export function DashboardContextProvider({ }, [initialTeam, updateTeamCookieState]) const value = { - features, team: initialTeam, teams: initialTeams, user: initialUser, diff --git a/src/features/dashboard/features.ts b/src/features/dashboard/features.ts deleted file mode 100644 index 29a1956ef..000000000 --- a/src/features/dashboard/features.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type DashboardFeatures = { - isAdmin: boolean -} - -export const DEFAULT_DASHBOARD_FEATURES = { - isAdmin: false, -} satisfies DashboardFeatures - -export type DashboardFeatureKey = keyof DashboardFeatures diff --git a/src/features/dashboard/sidebar/command.tsx b/src/features/dashboard/sidebar/command.tsx index 069362d7f..1ee3635d2 100644 --- a/src/features/dashboard/sidebar/command.tsx +++ b/src/features/dashboard/sidebar/command.tsx @@ -2,7 +2,7 @@ import { useRouter } from 'next/navigation' import { useState } from 'react' -import { getVisibleSidebarLinks, SIDEBAR_ALL_LINKS } from '@/configs/sidebar' +import { SIDEBAR_ALL_LINKS } from '@/configs/sidebar' import useKeydown from '@/lib/hooks/use-keydown' import { cn } from '@/lib/utils' import { @@ -29,9 +29,8 @@ export default function DashboardSidebarCommand({ className, }: DashboardSidebarCommandProps) { const [open, setOpen] = useState(false) - const { features, team } = useDashboard() + const { team } = useDashboard() const router = useRouter() - const links = getVisibleSidebarLinks(SIDEBAR_ALL_LINKS, features) const { open: sidebarOpen, openMobile: sidebarOpenMobile } = useSidebar() const isSidebarOpen = sidebarOpen || sidebarOpenMobile @@ -76,7 +75,7 @@ export default function DashboardSidebarCommand({ No results found. - {links.map((link) => ( + {SIDEBAR_ALL_LINKS.map((link) => ( { diff --git a/src/features/dashboard/sidebar/content.tsx b/src/features/dashboard/sidebar/content.tsx index e1d660d97..3d152bce2 100644 --- a/src/features/dashboard/sidebar/content.tsx +++ b/src/features/dashboard/sidebar/content.tsx @@ -3,11 +3,7 @@ import micromatch from 'micromatch' import { usePathname } from 'next/navigation' import { useMemo } from 'react' -import { - getVisibleSidebarLinks, - SIDEBAR_MAIN_LINKS, - type SidebarNavItem, -} from '@/configs/sidebar' +import { SIDEBAR_MAIN_LINKS, type SidebarNavItem } from '@/configs/sidebar' import { useIsMobile } from '@/lib/hooks/use-mobile' import { cn } from '@/lib/utils' @@ -40,7 +36,7 @@ const createGroupedLinks = (links: SidebarNavItem[]): GroupedLinks => { } export default function DashboardSidebarContent() { - const { features, team } = useDashboard() + const { team } = useDashboard() const selectedTeamSlug = team.slug const pathname = usePathname() @@ -48,9 +44,8 @@ export default function DashboardSidebarContent() { const { setOpenMobile } = useSidebar() const groupedNavLinks = useMemo( - () => - createGroupedLinks(getVisibleSidebarLinks(SIDEBAR_MAIN_LINKS, features)), - [features] + () => createGroupedLinks(SIDEBAR_MAIN_LINKS), + [] ) const isActive = (link: SidebarNavItem) => { From 13e60b88763fe289e835c828533a17bc6c9bd9af Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Fri, 12 Jun 2026 17:53:29 -0700 Subject: [PATCH 08/13] Target feature flags by team id --- src/app/dashboard/[teamSlug]/admin/page.tsx | 1 - src/core/server/feature-flags/context.ts | 1 - src/core/server/feature-flags/launchdarkly.ts | 2 -- 3 files changed, 4 deletions(-) diff --git a/src/app/dashboard/[teamSlug]/admin/page.tsx b/src/app/dashboard/[teamSlug]/admin/page.tsx index 457401a02..b6e25cb80 100644 --- a/src/app/dashboard/[teamSlug]/admin/page.tsx +++ b/src/app/dashboard/[teamSlug]/admin/page.tsx @@ -34,7 +34,6 @@ export default async function AdminPage({ params }: AdminPageProps) { const context = { userId: authContext.user.id, teamId: teamIdResult.data, - teamSlug, } const flags = await listFeatureFlags(context) const isAdmin = flags.some((flag) => flag.id === 'isAdmin' && flag.value) diff --git a/src/core/server/feature-flags/context.ts b/src/core/server/feature-flags/context.ts index c5c4db660..d01172752 100644 --- a/src/core/server/feature-flags/context.ts +++ b/src/core/server/feature-flags/context.ts @@ -1,5 +1,4 @@ export type FeatureFlagContextInput = { userId: string teamId?: string - teamSlug?: string } diff --git a/src/core/server/feature-flags/launchdarkly.ts b/src/core/server/feature-flags/launchdarkly.ts index 7063ca275..4c3cb6819 100644 --- a/src/core/server/feature-flags/launchdarkly.ts +++ b/src/core/server/feature-flags/launchdarkly.ts @@ -92,7 +92,6 @@ async function getInitializedLaunchDarklyClient() { function createLaunchDarklyContext({ userId, teamId, - teamSlug, }: FeatureFlagContextInput): LDContext { if (!teamId) { return { @@ -108,7 +107,6 @@ function createLaunchDarklyContext({ }, team: { key: teamId, - ...(teamSlug ? { slug: teamSlug } : {}), }, } } From 0cc9d9f4ad477825a4a67ba8f3ffc10fdfaade5c Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Fri, 12 Jun 2026 17:54:15 -0700 Subject: [PATCH 09/13] Drop leftover sidebar cleanup --- src/configs/sidebar.ts | 3 ++- src/features/dashboard/sidebar/content.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/configs/sidebar.ts b/src/configs/sidebar.ts index acdab8909..7ca6f3ed0 100644 --- a/src/configs/sidebar.ts +++ b/src/configs/sidebar.ts @@ -1,4 +1,4 @@ -import type { JSX } from 'react' +import { JSX } from 'react' import { AccountSettingsIcon, CardIcon, @@ -78,6 +78,7 @@ export const SIDEBAR_MAIN_LINKS: SidebarNavItem[] = [ group: 'team', activeMatch: `/dashboard/*/members`, }, + // Billing ...(INCLUDE_BILLING ? [ diff --git a/src/features/dashboard/sidebar/content.tsx b/src/features/dashboard/sidebar/content.tsx index 3d152bce2..3fb87453f 100644 --- a/src/features/dashboard/sidebar/content.tsx +++ b/src/features/dashboard/sidebar/content.tsx @@ -56,7 +56,7 @@ export default function DashboardSidebarContent() { return ( - {Object.entries(groupedNavLinks).map(([group, links]) => ( + {Object.entries(groupedNavLinks).map(([group, links], ix) => ( {group !== 'ungrouped' && ( {group} From c687ef31d4d6d32453cda21fe8e0c87b97124353 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Fri, 12 Jun 2026 18:06:51 -0700 Subject: [PATCH 10/13] Remove team slug from repository scope --- src/core/server/api/middlewares/repository.ts | 11 +---------- src/core/shared/repository-scope.ts | 1 - 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/core/server/api/middlewares/repository.ts b/src/core/server/api/middlewares/repository.ts index 71b6ecc5c..5d1b7b073 100644 --- a/src/core/server/api/middlewares/repository.ts +++ b/src/core/server/api/middlewares/repository.ts @@ -46,7 +46,7 @@ export function withTeamAuthedRequestRepository< createRepository: (scope: TeamRequestScope) => TRepository, extendContext: (repository: TRepository) => TContextExtension ) { - return t.middleware(({ ctx, next, input }) => { + return t.middleware(({ ctx, next }) => { if (!ctx.session) { throw unauthorizedUserError() } @@ -59,18 +59,9 @@ export function withTeamAuthedRequestRepository< throw forbiddenTeamAccessError() } - const teamSlug = - input && - typeof input === 'object' && - 'teamSlug' in input && - typeof input.teamSlug === 'string' - ? input.teamSlug - : undefined - const repository = createRepository({ accessToken: ctx.session.access_token, teamId: ctx.teamId, - teamSlug, }) return next({ diff --git a/src/core/shared/repository-scope.ts b/src/core/shared/repository-scope.ts index cc1e45da5..03af7669e 100644 --- a/src/core/shared/repository-scope.ts +++ b/src/core/shared/repository-scope.ts @@ -4,5 +4,4 @@ export interface RequestScope { export interface TeamRequestScope extends RequestScope { teamId: string - teamSlug?: string } From 6590c99666e8878babe44ec436323ef0f92610cc Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Fri, 12 Jun 2026 18:10:27 -0700 Subject: [PATCH 11/13] Assert LaunchDarkly team-id targeting --- src/app/dashboard/[teamSlug]/admin/page.tsx | 6 +---- src/configs/urls.ts | 1 - src/core/server/feature-flags/launchdarkly.ts | 2 +- .../dashboard/admin/feature-flags.tsx | 3 --- tests/unit/feature-flags.test.ts | 22 +++++++++++++++++++ 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/app/dashboard/[teamSlug]/admin/page.tsx b/src/app/dashboard/[teamSlug]/admin/page.tsx index b6e25cb80..184c7b42d 100644 --- a/src/app/dashboard/[teamSlug]/admin/page.tsx +++ b/src/app/dashboard/[teamSlug]/admin/page.tsx @@ -44,11 +44,7 @@ export default async function AdminPage({ params }: AdminPageProps) { return ( - + ) } diff --git a/src/configs/urls.ts b/src/configs/urls.ts index e88a61717..9412b2a97 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -20,7 +20,6 @@ export const PROTECTED_URLS = { GENERAL: (teamSlug: string) => `/dashboard/${teamSlug}/general`, KEYS: (teamSlug: string) => `/dashboard/${teamSlug}/keys`, MEMBERS: (teamSlug: string) => `/dashboard/${teamSlug}/members`, - ADMIN: (teamSlug: string) => `/dashboard/${teamSlug}/admin`, SANDBOXES: (teamSlug: string) => `/dashboard/${teamSlug}/sandboxes/monitoring`, diff --git a/src/core/server/feature-flags/launchdarkly.ts b/src/core/server/feature-flags/launchdarkly.ts index 4c3cb6819..30005f036 100644 --- a/src/core/server/feature-flags/launchdarkly.ts +++ b/src/core/server/feature-flags/launchdarkly.ts @@ -89,7 +89,7 @@ async function getInitializedLaunchDarklyClient() { return ldGlobal.__dashboardLaunchDarklyInitialization } -function createLaunchDarklyContext({ +export function createLaunchDarklyContext({ userId, teamId, }: FeatureFlagContextInput): LDContext { diff --git a/src/features/dashboard/admin/feature-flags.tsx b/src/features/dashboard/admin/feature-flags.tsx index 6f7339b35..88874771b 100644 --- a/src/features/dashboard/admin/feature-flags.tsx +++ b/src/features/dashboard/admin/feature-flags.tsx @@ -33,11 +33,9 @@ function FlagValueBadge({ value }: { value: unknown }) { export function FeatureFlagsTable({ flags, teamId, - teamSlug, }: { flags: EvaluatedFeatureFlag[] teamId: string - teamSlug: string }) { return (
@@ -47,7 +45,6 @@ export function FeatureFlagsTable({ Feature flags evaluated for this team.

- team: {teamSlug} team_id: {teamId}
diff --git a/tests/unit/feature-flags.test.ts b/tests/unit/feature-flags.test.ts index b42337078..becbbfc83 100644 --- a/tests/unit/feature-flags.test.ts +++ b/tests/unit/feature-flags.test.ts @@ -5,6 +5,7 @@ import type { 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', @@ -73,3 +74,24 @@ describe('createFeatureFlagService', () => { 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', + }) + }) +}) From de2eaa02fe4f6b62e6182ef8c466e4c43d8d3993 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Fri, 12 Jun 2026 18:15:51 -0700 Subject: [PATCH 12/13] Rename admin flag page to flags --- src/app/dashboard/[teamSlug]/{admin => flags}/page.tsx | 6 +++--- src/features/dashboard/{admin => flags}/feature-flags.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) rename src/app/dashboard/[teamSlug]/{admin => flags}/page.tsx (87%) rename src/features/dashboard/{admin => flags}/feature-flags.tsx (97%) diff --git a/src/app/dashboard/[teamSlug]/admin/page.tsx b/src/app/dashboard/[teamSlug]/flags/page.tsx similarity index 87% rename from src/app/dashboard/[teamSlug]/admin/page.tsx rename to src/app/dashboard/[teamSlug]/flags/page.tsx index 184c7b42d..b517b24db 100644 --- a/src/app/dashboard/[teamSlug]/admin/page.tsx +++ b/src/app/dashboard/[teamSlug]/flags/page.tsx @@ -3,16 +3,16 @@ 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/admin/feature-flags' +import { FeatureFlagsTable } from '@/features/dashboard/flags/feature-flags' import { Page } from '@/features/dashboard/layouts/page' -interface AdminPageProps { +interface FlagsPageProps { params: Promise<{ teamSlug: string }> } -export default async function AdminPage({ params }: AdminPageProps) { +export default async function FlagsPage({ params }: FlagsPageProps) { const [{ teamSlug }, authContext] = await Promise.all([ params, auth.getAuthContext(), diff --git a/src/features/dashboard/admin/feature-flags.tsx b/src/features/dashboard/flags/feature-flags.tsx similarity index 97% rename from src/features/dashboard/admin/feature-flags.tsx rename to src/features/dashboard/flags/feature-flags.tsx index 88874771b..2f9a09e51 100644 --- a/src/features/dashboard/admin/feature-flags.tsx +++ b/src/features/dashboard/flags/feature-flags.tsx @@ -40,7 +40,7 @@ export function FeatureFlagsTable({ return (
-

Admin

+

Feature Flags

Feature flags evaluated for this team.

From 6f74165f19719bd88208df40380390b4be6ce40a Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Fri, 12 Jun 2026 18:36:18 -0700 Subject: [PATCH 13/13] Use module-level LaunchDarkly client cache --- src/core/server/feature-flags/launchdarkly.ts | 36 +++++++------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/src/core/server/feature-flags/launchdarkly.ts b/src/core/server/feature-flags/launchdarkly.ts index 30005f036..6c95a373b 100644 --- a/src/core/server/feature-flags/launchdarkly.ts +++ b/src/core/server/feature-flags/launchdarkly.ts @@ -16,15 +16,9 @@ import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' const INITIALIZATION_TIMEOUT_SECONDS = 5 -type LaunchDarklyGlobal = typeof globalThis & { - __dashboardLaunchDarklyClient?: LDClient - __dashboardLaunchDarklyInitialization?: Promise - __dashboardLaunchDarklyReady?: boolean -} - -function getLaunchDarklyGlobal() { - return globalThis as LaunchDarklyGlobal -} +let launchDarklyClient: LDClient | undefined +let launchDarklyInitialization: Promise | undefined +let launchDarklyReady = false function getLaunchDarklySdkKey() { const key = process.env.LAUNCHDARKLY_SDK_KEY?.trim() @@ -38,17 +32,15 @@ function getLaunchDarklyClient() { return null } - const ldGlobal = getLaunchDarklyGlobal() - - if (!ldGlobal.__dashboardLaunchDarklyClient) { - ldGlobal.__dashboardLaunchDarklyClient = init(sdkKey, { + if (!launchDarklyClient) { + launchDarklyClient = init(sdkKey, { logger: basicLogger({ level: 'warn' }), }) - ldGlobal.__dashboardLaunchDarklyReady = false - ldGlobal.__dashboardLaunchDarklyInitialization = undefined + launchDarklyReady = false + launchDarklyInitialization = undefined } - return ldGlobal.__dashboardLaunchDarklyClient + return launchDarklyClient } async function getInitializedLaunchDarklyClient() { @@ -58,22 +50,20 @@ async function getInitializedLaunchDarklyClient() { return null } - const ldGlobal = getLaunchDarklyGlobal() - - if (ldGlobal.__dashboardLaunchDarklyReady) { + if (launchDarklyReady) { return client } - ldGlobal.__dashboardLaunchDarklyInitialization ??= client + launchDarklyInitialization ??= client .waitForInitialization({ timeout: INITIALIZATION_TIMEOUT_SECONDS, }) .then(() => { - ldGlobal.__dashboardLaunchDarklyReady = true + launchDarklyReady = true return client }) .catch((error: unknown) => { - ldGlobal.__dashboardLaunchDarklyInitialization = undefined + launchDarklyInitialization = undefined l.warn( { @@ -86,7 +76,7 @@ async function getInitializedLaunchDarklyClient() { return null }) - return ldGlobal.__dashboardLaunchDarklyInitialization + return launchDarklyInitialization } export function createLaunchDarklyContext({