Skip to content

Commit 3fe061e

Browse files
feat(feature-flags): AppConfig-backed gated feature flags (#5059)
* feat(feature-flags): AppConfig-backed gated feature flags * fix(ci): repoint 'Validate feature flags' step to env-flags.ts after rename * improvement(feature-flags): drop in-code defaults; fallback resolves a per-flag secret, gating is AppConfig-only * improvement(feature-flags): make flag names a closed set so every flag requires a fallback secret * improvement(feature-flags): single FEATURE_FLAGS registry — each entry defines name, description, and fallback in one place * improvement(feature-flags): fallback is the env secret key (keyof typeof env), resolved to a boolean
1 parent aaeef82 commit 3fe061e

166 files changed

Lines changed: 989 additions & 488 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
---
2+
description: Add a runtime gated feature flag (AppConfig-backed on prod, secret fallback off-prod), gated by org id, user id, or admin
3+
argument-hint: <flag-name>
4+
---
5+
6+
# Add Feature Flag Skill
7+
8+
You add a **runtime, gated feature flag** to Sim — one that can be turned on for specific orgs, users, or admins and changed on prod with no redeploy (AWS AppConfig). When AppConfig isn't the source of truth, the flag falls back to a single **secret** (on/off only).
9+
10+
## When to use this vs `env-flags.ts`
11+
12+
- **Feature flag** (`@/lib/core/config/feature-flags.ts`): per-request, gated by `userId`/`orgId`/admin, changeable at runtime. This skill.
13+
- **Env flag** (`@/lib/core/config/env-flags.ts`): deploy-time capability/environment detection (`isProd`, `isHosted`, `isBillingEnabled`). A module-load boolean. **Do not add gated flags here.**
14+
15+
If the user wants a fixed per-deployment toggle, send them to `env-flags.ts` instead.
16+
17+
## The flag model
18+
19+
A flag's **gating rule lives only in the hosted AppConfig document**. It is ON for a context when any clause matches:
20+
21+
```ts
22+
interface FeatureFlagRule {
23+
enabled?: boolean // global default for everyone
24+
orgIds?: string[] // allowlisted organization ids
25+
userIds?: string[] // allowlisted user ids
26+
admins?: boolean // platform admins (user.role === 'admin')
27+
}
28+
```
29+
30+
Critically, **none of this is expressible in code** — gating (especially `admins`) can only be set through AppConfig, so no environment can grant access from a code literal. Off-AppConfig (self-hosted/OSS/local), a flag is simply on or off, derived from its fallback secret.
31+
32+
## Steps
33+
34+
1. **Define the flag.** Add one entry to the `FEATURE_FLAGS` registry in `apps/sim/lib/core/config/feature-flags.ts`. Each entry is the flag's whole definition — name (kebab-case key), `description`, and the `fallback` secret consulted when AppConfig isn't the source of truth (truthy ⇒ on globally):
35+
36+
```ts
37+
const FEATURE_FLAGS = {
38+
'<flag-name>': {
39+
description: '<what this gates>',
40+
fallback: '<FLAG_SECRET>',
41+
},
42+
}
43+
```
44+
45+
`fallback` is the env/secret key (typed as `keyof typeof env`), so add `<FLAG_SECRET>` to `apps/sim/lib/core/config/env.ts` first (and the deployment's secret store) — it won't typecheck otherwise. Do **not** add org/user/admin defaults here — that gating exists only in AppConfig. Adding the entry makes `<flag-name>` a valid `FeatureFlagName`.
46+
47+
2. **Gate the call site.** Call `isFeatureEnabled` with whatever ids you have — admin status is resolved internally, so callers never pass it:
48+
49+
```ts
50+
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
51+
52+
if (await isFeatureEnabled('<flag-name>', { userId, orgId })) {
53+
// gated behavior
54+
}
55+
```
56+
57+
- Missing ids are fine — a clause with no matching id is skipped; with no `userId`, the admin clause resolves to `false` without a DB read.
58+
- Admin routes that already know the caller is an admin may pass `{ userId, isAdmin: true }` to skip the role lookup.
59+
- **Client/UI flags:** resolve server-side (in a server component, route, or loader) and pass the boolean down as a prop. There is no client AppConfig.
60+
61+
3. **(Prod) configure in AppConfig.** The infra `feature-flags` profile schema is permissive, so a new flag needs **no infra change**. Operators add the flag under `flags` in the hosted `feature-flags` document — including any `orgIds`/`userIds`/`admins` gating — and start a `sim-<env>-fast` deployment (see the AppConfig runbook in the infra README — same flow as `access-control`). The fallback secret only applies when AppConfig is disabled.
62+
63+
4. **Test.** Add a case to `apps/sim/lib/core/config/feature-flags.test.ts`: use `withAppConfig({ flags: { ... } })` to cover the gating rule (mock `isPlatformAdmin` for the `admins` clause), and toggle the fallback secret to cover the off-AppConfig path.
64+
65+
5. **Clean up after rollout.** When the feature ships to everyone, delete the flag's entry from `FEATURE_FLAGS`, the `<FLAG_SECRET>` env entry, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems.
66+
67+
## Notes
68+
69+
- Flag keys are `kebab-case`.
70+
- Never read flags via raw `fetch` or a new AppConfig client — always go through `isFeatureEnabled` / `getFeatureFlags`.
71+
- Never bake gating into code. The fallback is a single boolean secret; org/user/admin scoping is AppConfig-only.
72+
- The admin check reads the DB **replica** (`dbReplica`) and is resolved lazily, so an admin-gated flag adds at most one cheap replica read, and only when `admins` is the deciding clause.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Add Feature Flag Skill
2+
3+
You add a **runtime, gated feature flag** to Sim — one that can be turned on for specific orgs, users, or admins and changed on prod with no redeploy (AWS AppConfig). When AppConfig isn't the source of truth, the flag falls back to a single **secret** (on/off only).
4+
5+
## When to use this vs `env-flags.ts`
6+
7+
- **Feature flag** (`@/lib/core/config/feature-flags.ts`): per-request, gated by `userId`/`orgId`/admin, changeable at runtime. This skill.
8+
- **Env flag** (`@/lib/core/config/env-flags.ts`): deploy-time capability/environment detection (`isProd`, `isHosted`, `isBillingEnabled`). A module-load boolean. **Do not add gated flags here.**
9+
10+
If the user wants a fixed per-deployment toggle, send them to `env-flags.ts` instead.
11+
12+
## The flag model
13+
14+
A flag's **gating rule lives only in the hosted AppConfig document**. It is ON for a context when any clause matches:
15+
16+
```ts
17+
interface FeatureFlagRule {
18+
enabled?: boolean // global default for everyone
19+
orgIds?: string[] // allowlisted organization ids
20+
userIds?: string[] // allowlisted user ids
21+
admins?: boolean // platform admins (user.role === 'admin')
22+
}
23+
```
24+
25+
Critically, **none of this is expressible in code** — gating (especially `admins`) can only be set through AppConfig, so no environment can grant access from a code literal. Off-AppConfig (self-hosted/OSS/local), a flag is simply on or off, derived from its fallback secret.
26+
27+
## Steps
28+
29+
1. **Define the flag.** Add one entry to the `FEATURE_FLAGS` registry in `apps/sim/lib/core/config/feature-flags.ts`. Each entry is the flag's whole definition — name (kebab-case key), `description`, and the `fallback` secret consulted when AppConfig isn't the source of truth (truthy ⇒ on globally):
30+
31+
```ts
32+
const FEATURE_FLAGS = {
33+
'<flag-name>': {
34+
description: '<what this gates>',
35+
fallback: '<FLAG_SECRET>',
36+
},
37+
}
38+
```
39+
40+
`fallback` is the env/secret key (typed as `keyof typeof env`), so add `<FLAG_SECRET>` to `apps/sim/lib/core/config/env.ts` first (and the deployment's secret store) — it won't typecheck otherwise. Do **not** add org/user/admin defaults here — that gating exists only in AppConfig. Adding the entry makes `<flag-name>` a valid `FeatureFlagName`.
41+
42+
2. **Gate the call site.** Call `isFeatureEnabled` with whatever ids you have — admin status is resolved internally, so callers never pass it:
43+
44+
```ts
45+
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
46+
47+
if (await isFeatureEnabled('<flag-name>', { userId, orgId })) {
48+
// gated behavior
49+
}
50+
```
51+
52+
- Missing ids are fine — a clause with no matching id is skipped; with no `userId`, the admin clause resolves to `false` without a DB read.
53+
- Admin routes that already know the caller is an admin may pass `{ userId, isAdmin: true }` to skip the role lookup.
54+
- **Client/UI flags:** resolve server-side (in a server component, route, or loader) and pass the boolean down as a prop. There is no client AppConfig.
55+
56+
3. **(Prod) configure in AppConfig.** The infra `feature-flags` profile schema is permissive, so a new flag needs **no infra change**. Operators add the flag under `flags` in the hosted `feature-flags` document — including any `orgIds`/`userIds`/`admins` gating — and start a `sim-<env>-fast` deployment (see the AppConfig runbook in the infra README — same flow as `access-control`). The fallback secret only applies when AppConfig is disabled.
57+
58+
4. **Test.** Add a case to `apps/sim/lib/core/config/feature-flags.test.ts`: use `withAppConfig({ flags: { ... } })` to cover the gating rule (mock `isPlatformAdmin` for the `admins` clause), and toggle the fallback secret to cover the off-AppConfig path.
59+
60+
5. **Clean up after rollout.** When the feature ships to everyone, delete the flag's entry from `FEATURE_FLAGS`, the `<FLAG_SECRET>` env entry, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems.
61+
62+
## Notes
63+
64+
- Flag keys are `kebab-case`.
65+
- Never read flags via raw `fetch` or a new AppConfig client — always go through `isFeatureEnabled` / `getFeatureFlags`.
66+
- Never bake gating into code. The fallback is a single boolean secret; org/user/admin scoping is AppConfig-only.
67+
- The admin check reads the DB **replica** (`dbReplica`) and is resolved lazily, so an admin-gated flag adds at most one cheap replica read, and only when `admins` is the deciding clause.

.cursor/rules/sim-testing.mdc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ vi.useFakeTimers()
150150
| `@/lib/auth/hybrid` | `hybridAuthMock`, `hybridAuthMockFns` | `vi.mock('@/lib/auth/hybrid', () => hybridAuthMock)` |
151151
| `@/lib/copilot/request/http` | `copilotHttpMock`, `copilotHttpMockFns` | `vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)` |
152152
| `@/lib/core/config/env` | `envMock`, `createEnvMock(overrides)` | `vi.mock('@/lib/core/config/env', () => envMock)` |
153-
| `@/lib/core/config/feature-flags` | `featureFlagsMock` | `vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock)` |
153+
| `@/lib/core/config/env-flags` | `featureFlagsMock` | `vi.mock('@/lib/core/config/env-flags', () => featureFlagsMock)` |
154154
| `@/lib/core/config/redis` | `redisConfigMock`, `redisConfigMockFns` | `vi.mock('@/lib/core/config/redis', () => redisConfigMock)` |
155155
| `@/lib/core/security/encryption` | `encryptionMock`, `encryptionMockFns` | `vi.mock('@/lib/core/security/encryption', () => encryptionMock)` |
156156
| `@/lib/core/security/input-validation.server` | `inputValidationMock`, `inputValidationMockFns` | `vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)` |

.github/workflows/test-build.yml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,12 @@ jobs:
5555
- name: Install dependencies
5656
run: bun install --frozen-lockfile
5757

58-
- name: Validate feature flags
58+
- name: Validate env flags
5959
run: |
60-
FILE="apps/sim/lib/core/config/feature-flags.ts"
60+
FILE="apps/sim/lib/core/config/env-flags.ts"
6161
ERRORS=""
6262
63-
echo "Checking for hardcoded boolean feature flags..."
63+
echo "Checking for hardcoded boolean env flags..."
6464
6565
# Use perl for multiline matching to catch both:
6666
# export const isHosted = true
@@ -69,17 +69,17 @@ jobs:
6969
HARDCODED=$(perl -0777 -ne 'while (/export const (is[A-Za-z]+)\s*=\s*\n?\s*(true|false)\b/g) { print " $1 = $2\n" }' "$FILE")
7070
7171
if [ -n "$HARDCODED" ]; then
72-
ERRORS="${ERRORS}\n❌ Feature flags must not be hardcoded to boolean literals!\n\nFound hardcoded flags:\n${HARDCODED}\n\nFeature flags should derive their values from environment variables.\n"
72+
ERRORS="${ERRORS}\n❌ Env flags must not be hardcoded to boolean literals!\n\nFound hardcoded flags:\n${HARDCODED}\n\nEnv flags should derive their values from environment variables.\n"
7373
fi
7474
75-
echo "Checking feature flag naming conventions..."
75+
echo "Checking env flag naming conventions..."
7676
7777
# Check that all export const (except functions) start with 'is'
7878
# This finds exports like "export const someFlag" that don't start with "is" or "get"
7979
BAD_NAMES=$(grep -E "^export const [a-z]" "$FILE" | grep -vE "^export const (is|get)" | sed 's/export const \([a-zA-Z]*\).*/ \1/')
8080
8181
if [ -n "$BAD_NAMES" ]; then
82-
ERRORS="${ERRORS}\n❌ Feature flags must use 'is' prefix for boolean flags!\n\nFound incorrectly named flags:\n${BAD_NAMES}\n\nExample: 'hostedMode' should be 'isHostedMode'\n"
82+
ERRORS="${ERRORS}\n❌ Env flags must use 'is' prefix for boolean flags!\n\nFound incorrectly named flags:\n${BAD_NAMES}\n\nExample: 'hostedMode' should be 'isHostedMode'\n"
8383
fi
8484
8585
if [ -n "$ERRORS" ]; then
@@ -88,7 +88,7 @@ jobs:
8888
exit 1
8989
fi
9090
91-
echo "✅ All feature flags are properly configured"
91+
echo "✅ All env flags are properly configured"
9292
9393
- name: Check block registry invariants
9494
run: |

apps/sim/app/(auth)/components/oauth-provider-checker.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
isGoogleAuthDisabled,
55
isMicrosoftAuthDisabled,
66
isProd,
7-
} from '@/lib/core/config/feature-flags'
7+
} from '@/lib/core/config/env-flags'
88

99
export async function getOAuthProviderStatus() {
1010
const githubAvailable =

apps/sim/app/(auth)/signup/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Metadata } from 'next'
2-
import { isEmailSignupDisabled, isRegistrationDisabled } from '@/lib/core/config/feature-flags'
2+
import { isEmailSignupDisabled, isRegistrationDisabled } from '@/lib/core/config/env-flags'
33
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
44
import SignupForm from '@/app/(auth)/signup/signup-form'
55

apps/sim/app/(auth)/verify/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Metadata } from 'next'
2-
import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/feature-flags'
2+
import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/env-flags'
33
import { hasEmailService } from '@/lib/messaging/email/mailer'
44
import { VerifyContent } from '@/app/(auth)/verify/verify-content'
55

apps/sim/app/(landing)/components/legal-layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getNavBlogPosts } from '@/lib/blog/registry'
2-
import { isHosted } from '@/lib/core/config/feature-flags'
2+
import { isHosted } from '@/lib/core/config/env-flags'
33
import Footer from '@/app/(landing)/components/footer/footer'
44
import Navbar from '@/app/(landing)/components/navbar/navbar'
55

apps/sim/app/(landing)/contact/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Metadata } from 'next'
22
import { getNavBlogPosts } from '@/lib/blog/registry'
3-
import { isHosted } from '@/lib/core/config/feature-flags'
3+
import { isHosted } from '@/lib/core/config/env-flags'
44
import { SITE_URL } from '@/lib/core/utils/urls'
55
import { ContactForm } from '@/app/(landing)/components/contact/contact-form'
66
import Footer from '@/app/(landing)/components/footer/footer'

apps/sim/app/api/auth/[...all]/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ vi.mock('@/lib/auth/anonymous', () => ({
3131
createAnonymousSession: handlerMocks.createAnonymousSession,
3232
}))
3333

34-
vi.mock('@/lib/core/config/feature-flags', () => ({
34+
vi.mock('@/lib/core/config/env-flags', () => ({
3535
get isAuthDisabled() {
3636
return handlerMocks.isAuthDisabled
3737
},

0 commit comments

Comments
 (0)