Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
15 changes: 15 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 50 additions & 0 deletions src/app/dashboard/[teamSlug]/flags/page.tsx
Original file line number Diff line number Diff line change
@@ -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()
}
Comment thread
matthewlouisbrockman marked this conversation as resolved.

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 (
<Page>
<FeatureFlagsTable flags={flags} teamId={teamIdResult.data} />
</Page>
)
}
36 changes: 36 additions & 0 deletions src/configs/flags.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<T> = {
kind: 'json'
key: string
defaultValue: T
schema: z.ZodType<T>
description?: string
}

export type FeatureFlagDefinition =
| BooleanFeatureFlagDefinition
| JsonFeatureFlagDefinition<unknown>

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<string, FeatureFlagDefinition>
4 changes: 4 additions & 0 deletions src/core/server/feature-flags/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type FeatureFlagContextInput = {
userId: string
teamId?: string
}
62 changes: 62 additions & 0 deletions src/core/server/feature-flags/flags.server.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>
getJson<T>(
flag: JsonFeatureFlagDefinition<T>,
context: FeatureFlagContextInput
): Promise<unknown>
}

export type FeatureFlagService = {
getBoolean(
flag: BooleanFeatureFlagDefinition,
context: FeatureFlagContextInput
): Promise<boolean>
getJson<T>(
flag: JsonFeatureFlagDefinition<T>,
context: FeatureFlagContextInput
): Promise<T>
}

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()
Loading
Loading