Skip to content

feat(feature-flags): AppConfig-backed gated feature flags#5059

Merged
TheodoreSpeaks merged 7 commits into
stagingfrom
feat/appconfig-backed-feature-flag
Jun 16, 2026
Merged

feat(feature-flags): AppConfig-backed gated feature flags#5059
TheodoreSpeaks merged 7 commits into
stagingfrom
feat/appconfig-backed-feature-flag

Conversation

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator

Summary

  • New lib/core/config/feature-flags.ts: runtime feature flags gated by org id, user id, or admin — AppConfig-backed on prod (reuses fetchAppConfigProfile), with an in-file DEFAULT_FEATURE_FLAGS fallback off-prod (no env var)
  • Renamed the old env/deployment-detection module feature-flags.tsenv-flags.ts (it was never gated flags); rewrote all importers + the shared test mock (featureFlagsMockenvFlagsMock)
  • Admin gate resolved internally and lazily via new isPlatformAdmin(userId) reading the DB replica (dbReplica); only queries when admins is the deciding clause
  • add-feature-flag skill for Claude + Cursor
  • Infra (separate repo): adds a feature-flags AppConfig ConfigurationProfile alongside access-control

Type of Change

  • New feature

Testing

Tested manually. New feature-flags.test.ts (13 tests) + renamed-mock consumers pass. bun run lint, check:api-validation:strict, tsc --noEmit, and cdk synth all clean.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel

vercel Bot commented Jun 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Jun 16, 2026 1:43am

Request Review

@cursor

cursor Bot commented Jun 15, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Very wide import-path refactor touches auth, billing, and security-adjacent code; wrong mock or import could change behavior. New admin/replica lookup is only used by the flag evaluator today but will matter once flags gate real features.

Overview
Splits deploy-time toggles from runtime feature flags. Everything that used to live in feature-flags.ts (isProd, isHosted, isBillingEnabled, etc.) moves to new env-flags.ts; importers, Vitest mocks (featureFlagsMockenvFlagsMock), CI validation, and testing docs are updated to match. Runtime behavior for those booleans should be unchanged—this is mostly a rename and module boundary.

Adds a new feature-flags.ts for gated, no-redeploy flags on hosted. getFeatureFlags() loads the feature-flags AppConfig profile (same fetchAppConfigProfile pattern as access-control) when AppConfig is enabled; otherwise each registered flag falls back to a typed env secret (global on/off only). isFeatureEnabled(flag, { userId, orgId }) evaluates rules (enabled, orgIds, userIds, admins) with lazy isPlatformAdmin on dbReplica and an optional isAdmin override. The FEATURE_FLAGS registry is empty in this PR—no product call sites yet.

Operator/docs support: Claude/Cursor add-feature-flag skills document how to register flags and gate via AppConfig vs secrets. feature-flags.test.ts covers parsing, fallbacks, and admin gating. isPlatformAdmin is added in super-user.ts for the admin clause.

Reviewed by Cursor Bugbot for commit c82faeb. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread apps/sim/lib/core/config/feature-flags.ts
…ked-feature-flag

# Conflicts:
#	apps/sim/app/(auth)/components/oauth-provider-checker.tsx
#	apps/sim/app/(auth)/signup/page.tsx
#	apps/sim/app/api/webhooks/agentmail/route.ts
#	apps/sim/ee/access-control/utils/permission-check.ts
#	apps/sim/lib/copilot/tools/handlers/function-execute.ts
#	apps/sim/lib/copilot/tools/server/files/touch-plan.test.ts
#	apps/sim/lib/copilot/tools/server/files/touch-plan.ts
#	apps/sim/lib/copilot/tools/server/router.ts
#	apps/sim/lib/core/config/feature-flags.ts
#	apps/sim/lib/core/security/input-validation.server.ts
#	apps/sim/lib/table/service.ts
#	apps/sim/lib/webhooks/processor.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 0853240. Configure here.

parseConfig
)

return value ?? fallbackFlags()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hosted null uses env fallback

Medium Severity

When isAppConfigEnabled is true, getFeatureFlags still returns fallbackFlags() if fetchAppConfigProfile yields null (unseeded profile, cold-start failure, or empty payload). That path turns registered fallback env secrets into globally enabled rules, bypassing AppConfig org/user/admin gating—the opposite of the documented model where secrets apply only when AppConfig is off.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0853240. Configure here.

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

@greptile-apps

greptile-apps Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a runtime, AppConfig-backed feature flag system (lib/core/config/feature-flags.ts) with per-request gating by org id, user id, or admin role, and renames the existing env/deployment-detection module from feature-flags.tsenv-flags.ts, updating all 160+ importers, tests, and the shared mock.

  • New flag engine: getFeatureFlags reads a feature-flags AppConfig profile (stale-while-revalidate, ~30 s TTL) and falls back to per-flag secrets when AppConfig is not configured. isFeatureEnabled evaluates clauses in order (enableduserIdsorgIdsadmins) with lazy DB-replica admin resolution that short-circuits once any earlier clause matches.
  • env-flags.ts rename: All deployment-detection booleans (isProd, isHosted, isBillingEnabled, etc.) are moved verbatim; no logic changes. The shared test mock (featureFlagsMockenvFlagsMock) is renamed 1-to-1.
  • isPlatformAdmin addition: Reads user.role from dbReplica for admin gating; correctly documented as bounded-stale and appropriate for feature (not auth) decisions.

Confidence Score: 4/5

The core flag engine is well-structured and the AppConfig caching layer is solid; the rename sweep is mechanically clean and TypeScript confirms all import paths resolve.

The new feature-flag module is a pure addition with no flags registered yet, so there is no production gating logic to regress. The 160+ import renames are mechanical and verified by tsc. The one open question is that normalizeRule stores empty rule objects ({}) for AppConfig entries whose keys are all unrecognized, which could silently swallow operator typos; it evaluates to false (safe), but the discarded config is invisible without server-side logging.

apps/sim/lib/core/config/feature-flags.ts — normalizeRule empty-object handling is worth a second look before any real flags are deployed.

Important Files Changed

Filename Overview
apps/sim/lib/core/config/feature-flags.ts New AppConfig-backed feature flag engine with lazy admin resolution, stale-while-revalidate caching, and an empty registry (foundation PR). One minor issue: normalizeRule can return {} for objects with only unrecognized keys.
apps/sim/lib/core/config/env-flags.ts Content-identical move of all env/deployment-detection helpers from the old feature-flags.ts; no logic changes.
apps/sim/lib/core/config/feature-flags.test.ts 13 well-structured tests covering AppConfig path, fallback path, all gating clauses, and lazy admin resolution including short-circuit behavior.
apps/sim/lib/permissions/super-user.ts Adds isPlatformAdmin reading from dbReplica for admin-gated flag resolution; single-column replica read with correct bounded-staleness justification.
packages/testing/src/mocks/env-flags.mock.ts Renamed from feature-flags.mock.ts with identical flag set; a 1:1 rename with no regressions introduced relative to the prior mock.
.claude/commands/add-feature-flag.md New Claude/Cursor skill documenting the add-flag workflow, fallback vs AppConfig distinction, and cleanup guidance.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant isFeatureEnabled
    participant getFeatureFlags
    participant fetchAppConfigProfile
    participant evaluate
    participant isPlatformAdmin

    Caller->>isFeatureEnabled: "isFeatureEnabled(flag, { userId, orgId })"
    isFeatureEnabled->>getFeatureFlags: getFeatureFlags()
    alt isAppConfigEnabled
        getFeatureFlags->>fetchAppConfigProfile: fetchAppConfigProfile(ids, parseConfig)
        note over fetchAppConfigProfile: Stale-while-revalidate cache (~30s TTL)
        fetchAppConfigProfile-->>getFeatureFlags: "FeatureFlagsConfig | null"
    else fallback path
        getFeatureFlags-->>getFeatureFlags: fallbackFlags() from env secrets
    end
    getFeatureFlags-->>isFeatureEnabled: "{ flags }"
    isFeatureEnabled->>evaluate: evaluate(flags[flag], ctx)
    note over evaluate: 1. rule.enabled?
    note over evaluate: 2. userId in userIds?
    note over evaluate: 3. orgId in orgIds?
    alt "rule.admins && no earlier match"
        evaluate->>isPlatformAdmin: isPlatformAdmin(userId) via dbReplica
        isPlatformAdmin-->>evaluate: boolean
    end
    evaluate-->>isFeatureEnabled: boolean
    isFeatureEnabled-->>Caller: boolean
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant isFeatureEnabled
    participant getFeatureFlags
    participant fetchAppConfigProfile
    participant evaluate
    participant isPlatformAdmin

    Caller->>isFeatureEnabled: "isFeatureEnabled(flag, { userId, orgId })"
    isFeatureEnabled->>getFeatureFlags: getFeatureFlags()
    alt isAppConfigEnabled
        getFeatureFlags->>fetchAppConfigProfile: fetchAppConfigProfile(ids, parseConfig)
        note over fetchAppConfigProfile: Stale-while-revalidate cache (~30s TTL)
        fetchAppConfigProfile-->>getFeatureFlags: "FeatureFlagsConfig | null"
    else fallback path
        getFeatureFlags-->>getFeatureFlags: fallbackFlags() from env secrets
    end
    getFeatureFlags-->>isFeatureEnabled: "{ flags }"
    isFeatureEnabled->>evaluate: evaluate(flags[flag], ctx)
    note over evaluate: 1. rule.enabled?
    note over evaluate: 2. userId in userIds?
    note over evaluate: 3. orgId in orgIds?
    alt "rule.admins && no earlier match"
        evaluate->>isPlatformAdmin: isPlatformAdmin(userId) via dbReplica
        isPlatformAdmin-->>evaluate: boolean
    end
    evaluate-->>isFeatureEnabled: boolean
    isFeatureEnabled-->>Caller: boolean
Loading

Comments Outside Diff (1)

  1. apps/sim/lib/core/config/feature-flags.ts, line 1027-1038 (link)

    P2 Empty rule silently stored for objects with only unrecognized keys

    normalizeRule returns {} (truthy) when the AppConfig value is a valid object but contains no recognized properties — for example { "userID": ["u1"] } (typo) or { "enabled": "yes" } (string instead of boolean). parseConfig then stores this empty rule because if (rule) passes for {}. The stored rule always evaluates to false, which is the safe default, but operators get no indication that their configuration was silently dropped. Consider returning null for an empty-after-normalization rule so the key is omitted from flags entirely (consistent with how c: 'not-an-object' is dropped).

Reviews (1): Last reviewed commit: "improvement(feature-flags): fallback is ..." | Re-trigger Greptile

@TheodoreSpeaks TheodoreSpeaks merged commit 3fe061e into staging Jun 16, 2026
16 checks passed
@TheodoreSpeaks TheodoreSpeaks deleted the feat/appconfig-backed-feature-flag branch June 16, 2026 01:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant