Skip to content

Commit 1989bf8

Browse files
improvement(feature-flags): drop in-code defaults; fallback resolves a per-flag secret, gating is AppConfig-only
1 parent d13b230 commit 1989bf8

4 files changed

Lines changed: 53 additions & 40 deletions

File tree

.claude/commands/add-feature-flag.md

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
---
2-
description: Add a runtime gated feature flag (AppConfig-backed on prod, in-file default off-prod), gated by org id, user id, or admin
2+
description: Add a runtime gated feature flag (AppConfig-backed on prod, secret fallback off-prod), gated by org id, user id, or admin
33
argument-hint: <flag-name>
44
---
55

66
# Add Feature Flag Skill
77

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), falling back to an in-file default everywhere else.
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).
99

1010
## When to use this vs `env-flags.ts`
1111

@@ -16,7 +16,7 @@ If the user wants a fixed per-deployment toggle, send them to `env-flags.ts` ins
1616

1717
## The flag model
1818

19-
A flag is a named rule in `apps/sim/lib/core/config/feature-flags.ts`. It is ON for a context when **any** clause matches:
19+
A flag's **gating rule lives only in the hosted AppConfig document**. It is ON for a context when any clause matches:
2020

2121
```ts
2222
interface FeatureFlagRule {
@@ -27,19 +27,19 @@ interface FeatureFlagRule {
2727
}
2828
```
2929

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+
3032
## Steps
3133

32-
1. **Define the default.** Add an entry to `DEFAULT_FEATURE_FLAGS` in `apps/sim/lib/core/config/feature-flags.ts`. This is the source of truth off-AppConfig (self-hosted/OSS, local dev) and documents the intended shape. Use a **kebab-case** key:
34+
1. **Register the flag.** Add an entry to `FEATURE_FLAG_FALLBACKS` in `apps/sim/lib/core/config/feature-flags.ts`, mapping the flag name (kebab-case) to the secret consulted when AppConfig isn't the source of truth. A truthy secret turns the flag on globally:
3335

3436
```ts
35-
const DEFAULT_FEATURE_FLAGS: FeatureFlagsConfig = {
36-
flags: {
37-
'<flag-name>': { admins: true },
38-
},
37+
const FEATURE_FLAG_FALLBACKS = {
38+
'<flag-name>': () => env.<FLAG_SECRET>,
3939
}
4040
```
4141

42-
Default conservatively (usually `{ admins: true }` or empty `{}` so it's off for everyone until you roll out).
42+
Add `<FLAG_SECRET>` to `apps/sim/lib/core/config/env.ts` (and the deployment's secret store). Do **not** add any org/user/admin defaults here — that gating exists only in AppConfig.
4343

4444
2. **Gate the call site.** Call `isFeatureEnabled` with whatever ids you have — admin status is resolved internally, so callers never pass it:
4545

@@ -55,14 +55,15 @@ interface FeatureFlagRule {
5555
- Admin routes that already know the caller is an admin may pass `{ userId, isAdmin: true }` to skip the role lookup.
5656
- **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.
5757

58-
3. **(Prod) publish to AppConfig.** The infra `feature-flags` profile schema is permissive, so a new flag needs **no infra change**. Operators add the key under `flags` in the hosted `feature-flags` document and start a `sim-<env>-fast` deployment (see the AppConfig runbook in the infra README — same flow as `access-control`). Until then, prod uses whatever the document already contains; the in-file default applies only when AppConfig is disabled.
58+
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.
5959

60-
4. **Test.** Add a case to `apps/sim/lib/core/config/feature-flags.test.ts` covering the flag's gating (use the `withAppConfig({ flags: { ... } })` helper; mock `isPlatformAdmin` when the `admins` clause is involved).
60+
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.
6161

62-
5. **Clean up after rollout.** When the feature ships to everyone, delete the flag from `DEFAULT_FEATURE_FLAGS`, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems.
62+
5. **Clean up after rollout.** When the feature ships to everyone, delete the flag from `FEATURE_FLAG_FALLBACKS`, 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.
6363

6464
## Notes
6565

66-
- Tool IDs / flag keys are `kebab-case`.
66+
- Flag keys are `kebab-case`.
6767
- Never read flags via raw `fetch` or a new AppConfig client — always go through `isFeatureEnabled` / `getFeatureFlags`.
68+
- Never bake gating into code. The fallback is a single boolean secret; org/user/admin scoping is AppConfig-only.
6869
- 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/commands/add-feature-flag.md

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Add Feature Flag Skill
22

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), falling back to an in-file default everywhere else.
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).
44

55
## When to use this vs `env-flags.ts`
66

@@ -11,7 +11,7 @@ If the user wants a fixed per-deployment toggle, send them to `env-flags.ts` ins
1111

1212
## The flag model
1313

14-
A flag is a named rule in `apps/sim/lib/core/config/feature-flags.ts`. It is ON for a context when **any** clause matches:
14+
A flag's **gating rule lives only in the hosted AppConfig document**. It is ON for a context when any clause matches:
1515

1616
```ts
1717
interface FeatureFlagRule {
@@ -22,19 +22,19 @@ interface FeatureFlagRule {
2222
}
2323
```
2424

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+
2527
## Steps
2628

27-
1. **Define the default.** Add an entry to `DEFAULT_FEATURE_FLAGS` in `apps/sim/lib/core/config/feature-flags.ts`. This is the source of truth off-AppConfig (self-hosted/OSS, local dev) and documents the intended shape. Use a **kebab-case** key:
29+
1. **Register the flag.** Add an entry to `FEATURE_FLAG_FALLBACKS` in `apps/sim/lib/core/config/feature-flags.ts`, mapping the flag name (kebab-case) to the secret consulted when AppConfig isn't the source of truth. A truthy secret turns the flag on globally:
2830

2931
```ts
30-
const DEFAULT_FEATURE_FLAGS: FeatureFlagsConfig = {
31-
flags: {
32-
'<flag-name>': { admins: true },
33-
},
32+
const FEATURE_FLAG_FALLBACKS = {
33+
'<flag-name>': () => env.<FLAG_SECRET>,
3434
}
3535
```
3636

37-
Default conservatively (usually `{ admins: true }` or empty `{}` so it's off for everyone until you roll out).
37+
Add `<FLAG_SECRET>` to `apps/sim/lib/core/config/env.ts` (and the deployment's secret store). Do **not** add any org/user/admin defaults here — that gating exists only in AppConfig.
3838

3939
2. **Gate the call site.** Call `isFeatureEnabled` with whatever ids you have — admin status is resolved internally, so callers never pass it:
4040

@@ -50,14 +50,15 @@ interface FeatureFlagRule {
5050
- Admin routes that already know the caller is an admin may pass `{ userId, isAdmin: true }` to skip the role lookup.
5151
- **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.
5252

53-
3. **(Prod) publish to AppConfig.** The infra `feature-flags` profile schema is permissive, so a new flag needs **no infra change**. Operators add the key under `flags` in the hosted `feature-flags` document and start a `sim-<env>-fast` deployment (see the AppConfig runbook in the infra README — same flow as `access-control`). Until then, prod uses whatever the document already contains; the in-file default applies only when AppConfig is disabled.
53+
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.
5454

55-
4. **Test.** Add a case to `apps/sim/lib/core/config/feature-flags.test.ts` covering the flag's gating (use the `withAppConfig({ flags: { ... } })` helper; mock `isPlatformAdmin` when the `admins` clause is involved).
55+
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.
5656

57-
5. **Clean up after rollout.** When the feature ships to everyone, delete the flag from `DEFAULT_FEATURE_FLAGS`, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems.
57+
5. **Clean up after rollout.** When the feature ships to everyone, delete the flag from `FEATURE_FLAG_FALLBACKS`, 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.
5858

5959
## Notes
6060

61-
- Tool IDs / flag keys are `kebab-case`.
61+
- Flag keys are `kebab-case`.
6262
- Never read flags via raw `fetch` or a new AppConfig client — always go through `isFeatureEnabled` / `getFeatureFlags`.
63+
- Never bake gating into code. The fallback is a single boolean secret; org/user/admin scoping is AppConfig-only.
6364
- 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.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ describe('getFeatureFlags', () => {
4848
flagRef.isAppConfigEnabled = false
4949
})
5050

51-
it('returns the in-file default (empty) when AppConfig is disabled, without fetching', async () => {
51+
it('derives flags from fallback secrets (empty registry → empty) when AppConfig is disabled, without fetching', async () => {
5252
expect(await getFeatureFlags()).toEqual<FeatureFlagsConfig>({ flags: {} })
5353
expect(mockFetch).not.toHaveBeenCalled()
5454
})
@@ -72,7 +72,7 @@ describe('getFeatureFlags', () => {
7272
)
7373
})
7474

75-
it('falls back to the in-file default when the fetch yields null', async () => {
75+
it('falls back to the secret-derived document when the fetch yields null', async () => {
7676
flagRef.isAppConfigEnabled = true
7777
mockFetch.mockResolvedValue(null)
7878
expect(await getFeatureFlags()).toEqual<FeatureFlagsConfig>({ flags: {} })

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

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { fetchAppConfigProfile } from '@/lib/core/config/appconfig'
2-
import { env } from '@/lib/core/config/env'
2+
import { env, isTruthy } from '@/lib/core/config/env'
33
import { isAppConfigEnabled } from '@/lib/core/config/env-flags'
44

55
/**
@@ -37,15 +37,26 @@ export interface FeatureFlagContext {
3737
}
3838

3939
/**
40-
* Fallback flags used when AppConfig is not the source of truth (self-hosted/OSS,
41-
* local dev, or hosted without APPCONFIG_*). When AppConfig is enabled it fully
42-
* replaces this. Add/edit defaults here.
40+
* Registry of known feature flags. Each maps to the secret consulted ONLY when
41+
* AppConfig is not the source of truth (self-hosted/OSS, local dev, or hosted
42+
* without APPCONFIG_*). A truthy secret turns the flag on globally.
43+
*
44+
* Gating by org/user/admin is available ONLY through the hosted AppConfig document
45+
* — it deliberately cannot be expressed here, so no environment can grant (e.g.)
46+
* admin access from a code literal. To add a flag, register its name and the secret
47+
* to fall back on.
4348
*/
44-
const DEFAULT_FEATURE_FLAGS: FeatureFlagsConfig = {
45-
flags: {
46-
// e.g. 'new-canvas': { admins: true },
47-
// e.g. 'beta-export': { orgIds: ['org_123'], userIds: ['user_abc'] },
48-
},
49+
const FEATURE_FLAG_FALLBACKS: Record<string, () => string | boolean | number | undefined> = {
50+
// 'new-canvas': () => env.NEW_CANVAS_ENABLED,
51+
}
52+
53+
/** Build the fallback document from each flag's secret. Truthy secret ⇒ enabled. */
54+
function fallbackFlags(): FeatureFlagsConfig {
55+
const flags: Record<string, FeatureFlagRule> = {}
56+
for (const [name, readSecret] of Object.entries(FEATURE_FLAG_FALLBACKS)) {
57+
flags[name] = { enabled: isTruthy(readSecret()) }
58+
}
59+
return { flags }
4960
}
5061

5162
function normalizeIds(values: unknown): string[] | undefined {
@@ -114,11 +125,11 @@ async function evaluate(
114125

115126
/**
116127
* Resolve the full flag document. Reads from AWS AppConfig on hosted deployments
117-
* (cached, ~30s TTL, never blocks after the first fetch), otherwise returns the
118-
* in-file {@link DEFAULT_FEATURE_FLAGS}.
128+
* (cached, ~30s TTL, never blocks after the first fetch), otherwise derives each
129+
* flag's on/off state from its registered fallback secret ({@link fallbackFlags}).
119130
*/
120131
export async function getFeatureFlags(): Promise<FeatureFlagsConfig> {
121-
if (!isAppConfigEnabled) return DEFAULT_FEATURE_FLAGS
132+
if (!isAppConfigEnabled) return fallbackFlags()
122133

123134
const value = await fetchAppConfigProfile(
124135
{
@@ -129,7 +140,7 @@ export async function getFeatureFlags(): Promise<FeatureFlagsConfig> {
129140
parseConfig
130141
)
131142

132-
return value ?? DEFAULT_FEATURE_FLAGS
143+
return value ?? fallbackFlags()
133144
}
134145

135146
/** Resolve a single flag for a context. Admin status is resolved internally from `userId`. */

0 commit comments

Comments
 (0)