Skip to content

Commit 0853240

Browse files
improvement(feature-flags): make flag names a closed set so every flag requires a fallback secret
1 parent 1989bf8 commit 0853240

2 files changed

Lines changed: 41 additions & 19 deletions

File tree

apps/sim/lib/core/config/feature-flags.test.ts

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
* @vitest-environment node
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
5-
import type { FeatureFlagsConfig } from '@/lib/core/config/feature-flags'
5+
import type {
6+
FeatureFlagContext,
7+
FeatureFlagName,
8+
FeatureFlagsConfig,
9+
} from '@/lib/core/config/feature-flags'
610

711
const { mockFetch, mockIsPlatformAdmin, envRef, flagRef } = vi.hoisted(() => ({
812
mockFetch: vi.fn(),
@@ -42,6 +46,14 @@ function withAppConfig(doc: unknown) {
4246
mockFetch.mockImplementation((_ids, parse) => Promise.resolve(parse(doc)))
4347
}
4448

49+
/**
50+
* `isFeatureEnabled` only accepts registered `FeatureFlagName`s. The registry is
51+
* empty in this PR, so tests reference flags through the AppConfig document and
52+
* cast their throwaway names through this helper.
53+
*/
54+
const enabled = (flag: string, ctx?: FeatureFlagContext) =>
55+
isFeatureEnabled(flag as FeatureFlagName, ctx)
56+
4557
describe('getFeatureFlags', () => {
4658
beforeEach(() => {
4759
vi.clearAllMocks()
@@ -94,62 +106,62 @@ describe('isFeatureEnabled', () => {
94106

95107
it('returns false for an unknown flag', async () => {
96108
withAppConfig({ flags: {} })
97-
expect(await isFeatureEnabled('missing', { userId: 'u1' })).toBe(false)
109+
expect(await enabled('missing', { userId: 'u1' })).toBe(false)
98110
})
99111

100112
it('matches the global enabled clause', async () => {
101113
withAppConfig({ flags: { f: { enabled: true } } })
102-
expect(await isFeatureEnabled('f')).toBe(true)
114+
expect(await enabled('f')).toBe(true)
103115
})
104116

105117
it('matches the userId allowlist', async () => {
106118
withAppConfig({ flags: { f: { userIds: ['u1'] } } })
107-
expect(await isFeatureEnabled('f', { userId: 'u1' })).toBe(true)
108-
expect(await isFeatureEnabled('f', { userId: 'u2' })).toBe(false)
109-
expect(await isFeatureEnabled('f', {})).toBe(false)
119+
expect(await enabled('f', { userId: 'u1' })).toBe(true)
120+
expect(await enabled('f', { userId: 'u2' })).toBe(false)
121+
expect(await enabled('f', {})).toBe(false)
110122
})
111123

112124
it('matches the orgId allowlist', async () => {
113125
withAppConfig({ flags: { f: { orgIds: ['o1'] } } })
114-
expect(await isFeatureEnabled('f', { orgId: 'o1' })).toBe(true)
115-
expect(await isFeatureEnabled('f', { orgId: 'o2' })).toBe(false)
126+
expect(await enabled('f', { orgId: 'o1' })).toBe(true)
127+
expect(await enabled('f', { orgId: 'o2' })).toBe(false)
116128
})
117129

118130
describe('admin clause (lazy resolution)', () => {
119131
it('resolves admin from userId when admins is the deciding clause', async () => {
120132
withAppConfig({ flags: { f: { admins: true } } })
121133
mockIsPlatformAdmin.mockResolvedValue(true)
122-
expect(await isFeatureEnabled('f', { userId: 'u1' })).toBe(true)
134+
expect(await enabled('f', { userId: 'u1' })).toBe(true)
123135
expect(mockIsPlatformAdmin).toHaveBeenCalledWith('u1')
124136

125137
mockIsPlatformAdmin.mockResolvedValue(false)
126-
expect(await isFeatureEnabled('f', { userId: 'u2' })).toBe(false)
138+
expect(await enabled('f', { userId: 'u2' })).toBe(false)
127139
})
128140

129141
it('uses the isAdmin override without querying', async () => {
130142
withAppConfig({ flags: { f: { admins: true } } })
131-
expect(await isFeatureEnabled('f', { userId: 'u1', isAdmin: true })).toBe(true)
143+
expect(await enabled('f', { userId: 'u1', isAdmin: true })).toBe(true)
132144
expect(mockIsPlatformAdmin).not.toHaveBeenCalled()
133145
})
134146

135147
it('resolves to false without querying when userId is absent', async () => {
136148
withAppConfig({ flags: { f: { admins: true } } })
137-
expect(await isFeatureEnabled('f', { orgId: 'o1' })).toBe(false)
149+
expect(await enabled('f', { orgId: 'o1' })).toBe(false)
138150
expect(mockIsPlatformAdmin).not.toHaveBeenCalled()
139151
})
140152

141153
it('does not query when an earlier clause already matched', async () => {
142154
withAppConfig({ flags: { f: { enabled: true, admins: true } } })
143-
expect(await isFeatureEnabled('f', { userId: 'u1' })).toBe(true)
155+
expect(await enabled('f', { userId: 'u1' })).toBe(true)
144156

145157
withAppConfig({ flags: { g: { userIds: ['u1'], admins: true } } })
146-
expect(await isFeatureEnabled('g', { userId: 'u1' })).toBe(true)
158+
expect(await enabled('g', { userId: 'u1' })).toBe(true)
147159
expect(mockIsPlatformAdmin).not.toHaveBeenCalled()
148160
})
149161

150162
it('does not query when the rule has no admins clause', async () => {
151163
withAppConfig({ flags: { f: { userIds: ['u2'] } } })
152-
expect(await isFeatureEnabled('f', { userId: 'u1' })).toBe(false)
164+
expect(await enabled('f', { userId: 'u1' })).toBe(false)
153165
expect(mockIsPlatformAdmin).not.toHaveBeenCalled()
154166
})
155167
})

apps/sim/lib/core/config/feature-flags.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,24 @@ export interface FeatureFlagContext {
4646
* admin access from a code literal. To add a flag, register its name and the secret
4747
* to fall back on.
4848
*/
49-
const FEATURE_FLAG_FALLBACKS: Record<string, () => string | boolean | number | undefined> = {
49+
type FallbackSecret = () => string | boolean | number | undefined
50+
51+
const FEATURE_FLAG_FALLBACKS = {
5052
// 'new-canvas': () => env.NEW_CANVAS_ENABLED,
51-
}
53+
} satisfies Record<string, FallbackSecret>
54+
55+
/**
56+
* The closed set of known feature flags. Derived from the fallback registry, so a
57+
* flag cannot exist — or be checked — without a mandatory fallback secret.
58+
*/
59+
export type FeatureFlagName = keyof typeof FEATURE_FLAG_FALLBACKS
5260

5361
/** Build the fallback document from each flag's secret. Truthy secret ⇒ enabled. */
5462
function fallbackFlags(): FeatureFlagsConfig {
5563
const flags: Record<string, FeatureFlagRule> = {}
56-
for (const [name, readSecret] of Object.entries(FEATURE_FLAG_FALLBACKS)) {
64+
for (const [name, readSecret] of Object.entries(FEATURE_FLAG_FALLBACKS) as Array<
65+
[string, FallbackSecret]
66+
>) {
5767
flags[name] = { enabled: isTruthy(readSecret()) }
5868
}
5969
return { flags }
@@ -145,7 +155,7 @@ export async function getFeatureFlags(): Promise<FeatureFlagsConfig> {
145155

146156
/** Resolve a single flag for a context. Admin status is resolved internally from `userId`. */
147157
export async function isFeatureEnabled(
148-
flag: string,
158+
flag: FeatureFlagName,
149159
ctx: FeatureFlagContext = {}
150160
): Promise<boolean> {
151161
const { flags } = await getFeatureFlags()

0 commit comments

Comments
 (0)