From b485a5a0a2d3992cdb131e8d620c29338999f3b0 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 11:17:33 -0700 Subject: [PATCH 1/6] feat(feature-flags): AppConfig-backed gated feature flags --- .claude/commands/add-feature-flag.md | 68 +++ .cursor/commands/add-feature-flag.md | 63 +++ .cursor/rules/sim-testing.mdc | 2 +- .../components/oauth-provider-checker.tsx | 2 +- apps/sim/app/(auth)/signup/page.tsx | 2 +- apps/sim/app/(auth)/verify/page.tsx | 2 +- .../app/(landing)/components/legal-layout.tsx | 2 +- apps/sim/app/(landing)/contact/page.tsx | 2 +- apps/sim/app/api/auth/[...all]/route.test.ts | 2 +- apps/sim/app/api/auth/[...all]/route.ts | 2 +- apps/sim/app/api/auth/providers/route.ts | 2 +- apps/sim/app/api/auth/socket-token/route.ts | 2 +- apps/sim/app/api/billing/switch-plan/route.ts | 2 +- .../app/api/billing/update-cost/route.test.ts | 2 +- apps/sim/app/api/billing/update-cost/route.ts | 2 +- .../app/api/chat/manage/[id]/route.test.ts | 2 +- apps/sim/app/api/chat/manage/[id]/route.ts | 2 +- apps/sim/app/api/chat/utils.test.ts | 2 +- .../copilot/api-keys/validate/route.test.ts | 2 +- .../api/copilot/api-keys/validate/route.ts | 2 +- .../sim/app/api/cron/run-data-drains/route.ts | 2 +- .../app/api/files/serve/[...path]/route.ts | 2 +- .../app/api/function/execute/route.test.ts | 16 +- apps/sim/app/api/function/execute/route.ts | 2 +- apps/sim/app/api/mothership/execute/route.ts | 2 +- .../[id]/data-retention/route.ts | 2 +- .../[memberId]/usage-limit/route.test.ts | 2 +- .../members/[memberId]/usage-limit/route.ts | 2 +- .../app/api/schedules/execute/route.test.ts | 2 +- .../settings/allowed-integrations/route.ts | 2 +- .../api/settings/allowed-mcp-domains/route.ts | 2 +- .../api/settings/allowed-providers/route.ts | 2 +- apps/sim/app/api/speech/token/route.test.ts | 2 +- apps/sim/app/api/speech/token/route.ts | 2 +- .../[tableId]/delete-async/route.test.ts | 2 +- .../api/table/[tableId]/delete-async/route.ts | 2 +- .../api/table/[tableId]/export-async/route.ts | 2 +- .../api/table/[tableId]/import-async/route.ts | 2 +- apps/sim/app/api/table/import-async/route.ts | 2 +- apps/sim/app/api/telemetry/route.ts | 2 +- .../app/api/tools/onepassword/utils.test.ts | 2 +- apps/sim/app/api/tools/onepassword/utils.ts | 2 +- .../admin/organizations/[id]/billing/route.ts | 2 +- .../[id]/members/[memberId]/route.ts | 2 +- .../admin/organizations/[id]/members/route.ts | 2 +- apps/sim/app/api/v1/auth.ts | 2 +- apps/sim/app/api/wand/route.ts | 2 +- apps/sim/app/api/webhooks/agentmail/route.ts | 2 +- .../files/[fileId]/compiled-check/route.ts | 2 +- apps/sim/app/layout.tsx | 2 +- .../[workspaceId]/settings/[section]/page.tsx | 2 +- .../settings/components/general/general.tsx | 2 +- .../new-column-dropdown.tsx | 2 +- .../settings-sidebar/settings-sidebar.tsx | 2 +- apps/sim/blocks/utils.test.ts | 2 +- apps/sim/blocks/utils.ts | 2 +- .../emails/components/email-footer.tsx | 2 +- apps/sim/connectors/s3/s3.ts | 2 +- .../utils/permission-check.test.ts | 2 +- .../access-control/utils/permission-check.ts | 2 +- .../components/data-retention-settings.tsx | 2 +- apps/sim/ee/sso/components/sso-settings.tsx | 2 +- .../components/whitelabeling-settings.tsx | 2 +- .../handlers/agent/agent-handler.test.ts | 2 +- apps/sim/hooks/queries/copilot-keys.ts | 2 +- apps/sim/lib/a2a/push-notifications.ts | 2 +- apps/sim/lib/analytics/profound.ts | 2 +- apps/sim/lib/api-key/byok.test.ts | 2 +- apps/sim/lib/api-key/byok.ts | 2 +- apps/sim/lib/auth/access-control.test.ts | 2 +- apps/sim/lib/auth/access-control.ts | 2 +- apps/sim/lib/auth/auth-client.ts | 2 +- apps/sim/lib/auth/auth.ts | 2 +- apps/sim/lib/auth/ban.test.ts | 2 +- .../calculations/usage-monitor.test.ts | 2 +- .../lib/billing/calculations/usage-monitor.ts | 2 +- .../calculations/usage-reservation.test.ts | 2 +- .../billing/calculations/usage-reservation.ts | 2 +- .../sim/lib/billing/core/subscription.test.ts | 2 +- apps/sim/lib/billing/core/subscription.ts | 2 +- apps/sim/lib/billing/core/usage-log.test.ts | 2 +- apps/sim/lib/billing/core/usage.ts | 2 +- .../billing/organizations/seat-drift.test.ts | 2 +- .../lib/billing/organizations/seat-drift.ts | 2 +- .../lib/billing/organizations/seats.test.ts | 2 +- apps/sim/lib/billing/organizations/seats.ts | 2 +- apps/sim/lib/billing/storage/limits.ts | 2 +- apps/sim/lib/billing/storage/tracking.ts | 2 +- .../validation/seat-management.test.ts | 2 +- .../lib/billing/validation/seat-management.ts | 2 +- apps/sim/lib/copilot/chat/payload.test.ts | 4 +- apps/sim/lib/copilot/chat/payload.ts | 2 +- apps/sim/lib/copilot/chat/process-contents.ts | 2 +- .../tools/handlers/function-execute.ts | 2 +- .../server/blocks/get-blocks-metadata-tool.ts | 2 +- .../tools/server/blocks/get-trigger-blocks.ts | 2 +- .../copilot/tools/server/files/doc-compile.ts | 2 +- .../tools/server/files/edit-content.ts | 2 +- .../tools/server/files/touch-plan.test.ts | 2 +- .../copilot/tools/server/files/touch-plan.ts | 2 +- .../tools/server/files/workspace-file.ts | 2 +- apps/sim/lib/copilot/tools/server/router.ts | 2 +- .../workflow/edit-workflow/validation.test.ts | 4 +- .../workflow/edit-workflow/validation.ts | 2 +- apps/sim/lib/copilot/vfs/serializers.ts | 2 +- .../copilot/vfs/workflow-alias-resolver.ts | 2 +- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 2 +- apps/sim/lib/core/async-jobs/config.ts | 2 +- apps/sim/lib/core/config/env-flags.ts | 318 ++++++++++++++ .../sim/lib/core/config/feature-flags.test.ts | 156 +++++++ apps/sim/lib/core/config/feature-flags.ts | 394 +++++------------- apps/sim/lib/core/execution-limits/types.ts | 2 +- .../core/rate-limiter/rate-limiter.test.ts | 2 +- apps/sim/lib/core/rate-limiter/types.ts | 2 +- apps/sim/lib/core/security/csp.test.ts | 4 +- apps/sim/lib/core/security/csp.ts | 2 +- apps/sim/lib/core/security/deployment.ts | 2 +- .../core/security/input-validation.server.ts | 2 +- .../core/security/input-validation.test.ts | 4 +- .../sim/lib/core/security/input-validation.ts | 2 +- apps/sim/lib/core/utils/urls.test.ts | 2 +- apps/sim/lib/core/utils/urls.ts | 2 +- apps/sim/lib/data-drains/access.ts | 2 +- apps/sim/lib/data-drains/dispatcher.test.ts | 2 +- apps/sim/lib/data-drains/dispatcher.ts | 2 +- apps/sim/lib/invitations/core.test.ts | 2 +- apps/sim/lib/invitations/core.ts | 2 +- apps/sim/lib/knowledge/documents/service.ts | 2 +- apps/sim/lib/knowledge/reranker.ts | 2 +- apps/sim/lib/logs/execution/logger.test.ts | 4 +- apps/sim/lib/logs/execution/logger.ts | 2 +- apps/sim/lib/mcp/connection-manager.test.ts | 2 +- apps/sim/lib/mcp/connection-manager.ts | 2 +- apps/sim/lib/mcp/domain-check.test.ts | 2 +- apps/sim/lib/mcp/domain-check.ts | 2 +- apps/sim/lib/mcp/service.ts | 2 +- apps/sim/lib/messaging/lifecycle.ts | 2 +- apps/sim/lib/mothership/inbox/executor.ts | 2 +- apps/sim/lib/permissions/super-user.ts | 18 +- .../lib/table/__tests__/update-row.test.ts | 2 +- apps/sim/lib/table/backfill-runner.ts | 2 +- apps/sim/lib/table/service.ts | 2 +- apps/sim/lib/table/workflow-columns.ts | 2 +- apps/sim/lib/webhooks/processor.test.ts | 4 +- apps/sim/lib/webhooks/processor.ts | 2 +- .../sim/lib/workflows/subblocks/visibility.ts | 2 +- apps/sim/lib/workspaces/policy.test.ts | 2 +- apps/sim/lib/workspaces/policy.ts | 2 +- apps/sim/next.config.ts | 2 +- apps/sim/providers/index.test.ts | 2 +- apps/sim/providers/index.ts | 2 +- apps/sim/providers/utils.test.ts | 2 +- apps/sim/providers/utils.ts | 2 +- apps/sim/proxy.ts | 2 +- apps/sim/scripts/process-docs.ts | 2 +- apps/sim/tools/index.test.ts | 2 +- apps/sim/tools/index.ts | 2 +- apps/sim/tools/supabase/utils.test.ts | 4 +- ...eature-flags.mock.ts => env-flags.mock.ts} | 6 +- packages/testing/src/mocks/index.ts | 4 +- 160 files changed, 902 insertions(+), 457 deletions(-) create mode 100644 .claude/commands/add-feature-flag.md create mode 100644 .cursor/commands/add-feature-flag.md create mode 100644 apps/sim/lib/core/config/env-flags.ts create mode 100644 apps/sim/lib/core/config/feature-flags.test.ts rename packages/testing/src/mocks/{feature-flags.mock.ts => env-flags.mock.ts} (88%) diff --git a/.claude/commands/add-feature-flag.md b/.claude/commands/add-feature-flag.md new file mode 100644 index 00000000000..2a55a38128d --- /dev/null +++ b/.claude/commands/add-feature-flag.md @@ -0,0 +1,68 @@ +--- +description: Add a runtime gated feature flag (AppConfig-backed on prod, in-file default off-prod), gated by org id, user id, or admin +argument-hint: +--- + +# Add Feature Flag Skill + +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. + +## When to use this vs `env-flags.ts` + +- **Feature flag** (`@/lib/core/config/feature-flags.ts`): per-request, gated by `userId`/`orgId`/admin, changeable at runtime. This skill. +- **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.** + +If the user wants a fixed per-deployment toggle, send them to `env-flags.ts` instead. + +## The flag model + +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: + +```ts +interface FeatureFlagRule { + enabled?: boolean // global default for everyone + orgIds?: string[] // allowlisted organization ids + userIds?: string[] // allowlisted user ids + admins?: boolean // platform admins (user.role === 'admin') +} +``` + +## Steps + +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: + + ```ts + const DEFAULT_FEATURE_FLAGS: FeatureFlagsConfig = { + flags: { + '': { admins: true }, + }, + } + ``` + + Default conservatively (usually `{ admins: true }` or empty `{}` so it's off for everyone until you roll out). + +2. **Gate the call site.** Call `isFeatureEnabled` with whatever ids you have — admin status is resolved internally, so callers never pass it: + + ```ts + import { isFeatureEnabled } from '@/lib/core/config/feature-flags' + + if (await isFeatureEnabled('', { userId, orgId })) { + // gated behavior + } + ``` + + - 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. + - Admin routes that already know the caller is an admin may pass `{ userId, isAdmin: true }` to skip the role lookup. + - **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. + +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--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. + +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). + +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. + +## Notes + +- Tool IDs / flag keys are `kebab-case`. +- Never read flags via raw `fetch` or a new AppConfig client — always go through `isFeatureEnabled` / `getFeatureFlags`. +- 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. diff --git a/.cursor/commands/add-feature-flag.md b/.cursor/commands/add-feature-flag.md new file mode 100644 index 00000000000..34c010f5386 --- /dev/null +++ b/.cursor/commands/add-feature-flag.md @@ -0,0 +1,63 @@ +# Add Feature Flag Skill + +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. + +## When to use this vs `env-flags.ts` + +- **Feature flag** (`@/lib/core/config/feature-flags.ts`): per-request, gated by `userId`/`orgId`/admin, changeable at runtime. This skill. +- **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.** + +If the user wants a fixed per-deployment toggle, send them to `env-flags.ts` instead. + +## The flag model + +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: + +```ts +interface FeatureFlagRule { + enabled?: boolean // global default for everyone + orgIds?: string[] // allowlisted organization ids + userIds?: string[] // allowlisted user ids + admins?: boolean // platform admins (user.role === 'admin') +} +``` + +## Steps + +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: + + ```ts + const DEFAULT_FEATURE_FLAGS: FeatureFlagsConfig = { + flags: { + '': { admins: true }, + }, + } + ``` + + Default conservatively (usually `{ admins: true }` or empty `{}` so it's off for everyone until you roll out). + +2. **Gate the call site.** Call `isFeatureEnabled` with whatever ids you have — admin status is resolved internally, so callers never pass it: + + ```ts + import { isFeatureEnabled } from '@/lib/core/config/feature-flags' + + if (await isFeatureEnabled('', { userId, orgId })) { + // gated behavior + } + ``` + + - 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. + - Admin routes that already know the caller is an admin may pass `{ userId, isAdmin: true }` to skip the role lookup. + - **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. + +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--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. + +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). + +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. + +## Notes + +- Tool IDs / flag keys are `kebab-case`. +- Never read flags via raw `fetch` or a new AppConfig client — always go through `isFeatureEnabled` / `getFeatureFlags`. +- 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. diff --git a/.cursor/rules/sim-testing.mdc b/.cursor/rules/sim-testing.mdc index ffb2f857016..ca3ceb1e946 100644 --- a/.cursor/rules/sim-testing.mdc +++ b/.cursor/rules/sim-testing.mdc @@ -150,7 +150,7 @@ vi.useFakeTimers() | `@/lib/auth/hybrid` | `hybridAuthMock`, `hybridAuthMockFns` | `vi.mock('@/lib/auth/hybrid', () => hybridAuthMock)` | | `@/lib/copilot/request/http` | `copilotHttpMock`, `copilotHttpMockFns` | `vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)` | | `@/lib/core/config/env` | `envMock`, `createEnvMock(overrides)` | `vi.mock('@/lib/core/config/env', () => envMock)` | -| `@/lib/core/config/feature-flags` | `featureFlagsMock` | `vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock)` | +| `@/lib/core/config/env-flags` | `featureFlagsMock` | `vi.mock('@/lib/core/config/env-flags', () => featureFlagsMock)` | | `@/lib/core/config/redis` | `redisConfigMock`, `redisConfigMockFns` | `vi.mock('@/lib/core/config/redis', () => redisConfigMock)` | | `@/lib/core/security/encryption` | `encryptionMock`, `encryptionMockFns` | `vi.mock('@/lib/core/security/encryption', () => encryptionMock)` | | `@/lib/core/security/input-validation.server` | `inputValidationMock`, `inputValidationMockFns` | `vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)` | diff --git a/apps/sim/app/(auth)/components/oauth-provider-checker.tsx b/apps/sim/app/(auth)/components/oauth-provider-checker.tsx index 73a95f98b02..835d4330e7b 100644 --- a/apps/sim/app/(auth)/components/oauth-provider-checker.tsx +++ b/apps/sim/app/(auth)/components/oauth-provider-checker.tsx @@ -1,5 +1,5 @@ import { env } from '@/lib/core/config/env' -import { isGithubAuthDisabled, isGoogleAuthDisabled, isProd } from '@/lib/core/config/feature-flags' +import { isGithubAuthDisabled, isGoogleAuthDisabled, isProd } from '@/lib/core/config/env-flags' export async function getOAuthProviderStatus() { const githubAvailable = diff --git a/apps/sim/app/(auth)/signup/page.tsx b/apps/sim/app/(auth)/signup/page.tsx index 1f01e004643..c79ae769aea 100644 --- a/apps/sim/app/(auth)/signup/page.tsx +++ b/apps/sim/app/(auth)/signup/page.tsx @@ -1,5 +1,5 @@ import type { Metadata } from 'next' -import { isRegistrationDisabled } from '@/lib/core/config/feature-flags' +import { isRegistrationDisabled } from '@/lib/core/config/env-flags' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' import SignupForm from '@/app/(auth)/signup/signup-form' diff --git a/apps/sim/app/(auth)/verify/page.tsx b/apps/sim/app/(auth)/verify/page.tsx index 70c5484144c..c8825186d02 100644 --- a/apps/sim/app/(auth)/verify/page.tsx +++ b/apps/sim/app/(auth)/verify/page.tsx @@ -1,5 +1,5 @@ import type { Metadata } from 'next' -import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/feature-flags' +import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/env-flags' import { hasEmailService } from '@/lib/messaging/email/mailer' import { VerifyContent } from '@/app/(auth)/verify/verify-content' diff --git a/apps/sim/app/(landing)/components/legal-layout.tsx b/apps/sim/app/(landing)/components/legal-layout.tsx index 3f5ebdee8de..029bbff3fae 100644 --- a/apps/sim/app/(landing)/components/legal-layout.tsx +++ b/apps/sim/app/(landing)/components/legal-layout.tsx @@ -1,5 +1,5 @@ import { getNavBlogPosts } from '@/lib/blog/registry' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import Footer from '@/app/(landing)/components/footer/footer' import Navbar from '@/app/(landing)/components/navbar/navbar' diff --git a/apps/sim/app/(landing)/contact/page.tsx b/apps/sim/app/(landing)/contact/page.tsx index 6d2b00ef700..2cd9e4bcf18 100644 --- a/apps/sim/app/(landing)/contact/page.tsx +++ b/apps/sim/app/(landing)/contact/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next' import { getNavBlogPosts } from '@/lib/blog/registry' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import { SITE_URL } from '@/lib/core/utils/urls' import { ContactForm } from '@/app/(landing)/components/contact/contact-form' import Footer from '@/app/(landing)/components/footer/footer' diff --git a/apps/sim/app/api/auth/[...all]/route.test.ts b/apps/sim/app/api/auth/[...all]/route.test.ts index f87f1a01673..0d82eac149d 100644 --- a/apps/sim/app/api/auth/[...all]/route.test.ts +++ b/apps/sim/app/api/auth/[...all]/route.test.ts @@ -31,7 +31,7 @@ vi.mock('@/lib/auth/anonymous', () => ({ createAnonymousSession: handlerMocks.createAnonymousSession, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isAuthDisabled() { return handlerMocks.isAuthDisabled }, diff --git a/apps/sim/app/api/auth/[...all]/route.ts b/apps/sim/app/api/auth/[...all]/route.ts index 6ff9bfd6db2..8456afff4cf 100644 --- a/apps/sim/app/api/auth/[...all]/route.ts +++ b/apps/sim/app/api/auth/[...all]/route.ts @@ -2,7 +2,7 @@ import { toNextJsHandler } from 'better-auth/next-js' import { type NextRequest, NextResponse } from 'next/server' import { auth } from '@/lib/auth' import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous' -import { isAuthDisabled } from '@/lib/core/config/feature-flags' +import { isAuthDisabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/auth/providers/route.ts b/apps/sim/app/api/auth/providers/route.ts index 72a29bae7be..3f7cae65f58 100644 --- a/apps/sim/app/api/auth/providers/route.ts +++ b/apps/sim/app/api/auth/providers/route.ts @@ -2,7 +2,7 @@ import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { getAuthProvidersContract } from '@/lib/api/contracts/auth' import { parseRequest } from '@/lib/api/server' -import { isRegistrationDisabled } from '@/lib/core/config/feature-flags' +import { isRegistrationDisabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' diff --git a/apps/sim/app/api/auth/socket-token/route.ts b/apps/sim/app/api/auth/socket-token/route.ts index c7b0dc618c8..14fe571c1dd 100644 --- a/apps/sim/app/api/auth/socket-token/route.ts +++ b/apps/sim/app/api/auth/socket-token/route.ts @@ -3,7 +3,7 @@ import { toError } from '@sim/utils/errors' import { headers } from 'next/headers' import { type NextRequest, NextResponse } from 'next/server' import { auth } from '@/lib/auth' -import { isAuthDisabled } from '@/lib/core/config/feature-flags' +import { isAuthDisabled } from '@/lib/core/config/env-flags' import { enforceIpRateLimit } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/billing/switch-plan/route.ts b/apps/sim/app/api/billing/switch-plan/route.ts index 33a9f40a082..c066afbedb0 100644 --- a/apps/sim/app/api/billing/switch-plan/route.ts +++ b/apps/sim/app/api/billing/switch-plan/route.ts @@ -19,7 +19,7 @@ import { hasUsableSubscriptionStatus, isOrgScopedSubscription, } from '@/lib/billing/subscriptions/utils' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' diff --git a/apps/sim/app/api/billing/update-cost/route.test.ts b/apps/sim/app/api/billing/update-cost/route.test.ts index 1fb8f8c294d..b2a2b1a37a8 100644 --- a/apps/sim/app/api/billing/update-cost/route.test.ts +++ b/apps/sim/app/api/billing/update-cost/route.test.ts @@ -40,7 +40,7 @@ vi.mock('@/lib/billing/threshold-billing', () => ({ checkAndBillOverageThreshold: mockCheckAndBillOverageThreshold, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true, })) diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index 92ccce1e8a8..f2f65d482d8 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -14,7 +14,7 @@ import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { checkInternalApiKey } from '@/lib/copilot/request/http' import { withIncomingGoSpan } from '@/lib/copilot/request/otel' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index e11b1b548e7..38b24348167 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -33,7 +33,7 @@ const mockNotifySocketDeploymentChanged = workflowsOrchestrationMockFns.mockNotifySocketDeploymentChanged vi.mock('@sim/audit', () => auditMock) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ isDev: true, isHosted: false, isProd: false, diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 0115be7099b..707051d7e9f 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -8,7 +8,7 @@ import type { NextRequest } from 'next/server' import { chatIdParamsSchema, updateChatContract } from '@/lib/api/contracts/chats' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { isDev } from '@/lib/core/config/feature-flags' +import { isDev } from '@/lib/core/config/env-flags' import { encryptSecret } from '@/lib/core/security/encryption' import { getEmailDomain } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 337310d6303..b619919f97f 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -66,7 +66,7 @@ vi.mock('@/lib/core/security/deployment', () => ({ isEmailAllowed: mockIsEmailAllowed, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ isDev: true, isHosted: false, isProd: false, diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts index 0e410018d60..33eabf86451 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts @@ -42,7 +42,7 @@ vi.mock('@/lib/copilot/request/otel', () => ({ ) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn() }), })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isHosted() { return mockFlags.isHosted }, diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.ts b/apps/sim/app/api/copilot/api-keys/validate/route.ts index c011f663ed2..b13d2b11777 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.ts @@ -14,7 +14,7 @@ import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { checkInternalApiKey } from '@/lib/copilot/request/http' import { withIncomingGoSpan } from '@/lib/copilot/request/otel' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotApiKeysValidate') diff --git a/apps/sim/app/api/cron/run-data-drains/route.ts b/apps/sim/app/api/cron/run-data-drains/route.ts index 939d75419a6..61416bb8f6d 100644 --- a/apps/sim/app/api/cron/run-data-drains/route.ts +++ b/apps/sim/app/api/cron/run-data-drains/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' -import { isBillingEnabled, isDataDrainsEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled, isDataDrainsEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { dispatchDueDrains } from '@/lib/data-drains/dispatcher' diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 03de2e41c4b..3aea989d032 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -10,7 +10,7 @@ import { getE2BDocFormat, loadCompiledDocByExt, } from '@/lib/copilot/tools/server/files/doc-compile' -import { isE2BDocEnabled } from '@/lib/core/config/feature-flags' +import { isE2BDocEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { runSandboxTask } from '@/lib/execution/sandbox/run-task' import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads' diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 958361bfb7b..f14f6a1bfa8 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -5,7 +5,7 @@ */ import { createMockRequest, - featureFlagsMock, + envFlagsMock, hybridAuthMockFns, workflowsUtilsMock, } from '@sim/testing' @@ -94,7 +94,7 @@ vi.mock('@/lib/uploads', () => ({ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) import { validateProxyUrl } from '@/lib/core/security/input-validation' import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache' @@ -105,7 +105,7 @@ import { POST } from '@/app/api/function/execute/route' describe('Function Execute API Route', () => { beforeEach(() => { vi.clearAllMocks() - featureFlagsMock.isE2bEnabled = false + envFlagsMock.isE2bEnabled = false hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ success: true, @@ -341,7 +341,7 @@ describe('Function Execute API Route', () => { }) it('exports multiple declared sandbox output files', async () => { - featureFlagsMock.isE2bEnabled = true + envFlagsMock.isE2bEnabled = true mockExecuteInE2B.mockResolvedValueOnce({ result: 'done', stdout: 'ok', @@ -409,7 +409,7 @@ describe('Function Execute API Route', () => { }) it('prevalidates all sandbox output destinations before writing any files', async () => { - featureFlagsMock.isE2bEnabled = true + envFlagsMock.isE2bEnabled = true mockExecuteInE2B.mockResolvedValueOnce({ result: 'done', stdout: 'ok', @@ -453,7 +453,7 @@ describe('Function Execute API Route', () => { }) it('rejects duplicate sandbox output destinations before writing files', async () => { - featureFlagsMock.isE2bEnabled = true + envFlagsMock.isE2bEnabled = true mockExecuteInE2B.mockResolvedValueOnce({ result: 'done', stdout: 'ok', @@ -498,7 +498,7 @@ describe('Function Execute API Route', () => { }) it('returns a targeted error when a declared sandbox output is missing', async () => { - featureFlagsMock.isE2bEnabled = true + envFlagsMock.isE2bEnabled = true mockExecuteInE2B.mockResolvedValueOnce({ result: 'done', stdout: 'ok', @@ -571,7 +571,7 @@ describe('Function Execute API Route', () => { }) it('rejects large refs in runtimes without ref-native helpers', async () => { - featureFlagsMock.isE2bEnabled = true + envFlagsMock.isE2bEnabled = true const req = createMockRequest('POST', { code: 'echo "$__blockRef_0"', language: 'shell', diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index a3035607e52..2b723c667dc 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -14,7 +14,7 @@ import { validateWorkspaceFileWriteTarget, writeWorkspaceFileByPath, } from '@/lib/copilot/vfs/resource-writer' -import { isE2bEnabled } from '@/lib/core/config/feature-flags' +import { isE2bEnabled } from '@/lib/core/config/env-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { executeInE2B, executeShellInE2B } from '@/lib/execution/e2b' diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 3a49cc52ef7..65ef34d5491 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -14,7 +14,7 @@ import { import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' import type { StreamEvent } from '@/lib/copilot/request/types' -import { isE2BDocEnabled } from '@/lib/core/config/feature-flags' +import { isE2BDocEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildUserSkillTool } from '@/lib/mothership/skills' import { diff --git a/apps/sim/app/api/organizations/[id]/data-retention/route.ts b/apps/sim/app/api/organizations/[id]/data-retention/route.ts index ea2ae9c6aca..65e291a00d3 100644 --- a/apps/sim/app/api/organizations/[id]/data-retention/route.ts +++ b/apps/sim/app/api/organizations/[id]/data-retention/route.ts @@ -12,7 +12,7 @@ import { type OrganizationRetentionSettings, } from '@/lib/billing/cleanup-dispatcher' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('DataRetentionAPI') diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts index ec4669e9549..e92f8e1ce6a 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts @@ -37,7 +37,7 @@ vi.mock('@/lib/billing/organizations/member-limits', () => ({ setOrgMemberUsageLimit: mockSetOrgMemberUsageLimit, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isHosted() { return mockFlags.isHosted }, diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.ts index ad04ae4bbff..ad85d7ef83e 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.ts @@ -14,7 +14,7 @@ import { getOrgMemberWorkspaceUsage, setOrgMemberUsageLimit, } from '@/lib/billing/organizations/member-limits' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('OrgMemberUsageLimitAPI') diff --git a/apps/sim/app/api/schedules/execute/route.test.ts b/apps/sim/app/api/schedules/execute/route.test.ts index 2d0fa95ff55..7b61e3198a1 100644 --- a/apps/sim/app/api/schedules/execute/route.test.ts +++ b/apps/sim/app/api/schedules/execute/route.test.ts @@ -61,7 +61,7 @@ vi.mock('@/background/schedule-execution', () => ({ }), })) -vi.mock('@/lib/core/config/feature-flags', () => mockFeatureFlags) +vi.mock('@/lib/core/config/env-flags', () => mockFeatureFlags) vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: vi.fn().mockResolvedValue({ diff --git a/apps/sim/app/api/settings/allowed-integrations/route.ts b/apps/sim/app/api/settings/allowed-integrations/route.ts index 7f4a45dada4..19da51361ca 100644 --- a/apps/sim/app/api/settings/allowed-integrations/route.ts +++ b/apps/sim/app/api/settings/allowed-integrations/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' -import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const GET = withRouteHandler(async () => { diff --git a/apps/sim/app/api/settings/allowed-mcp-domains/route.ts b/apps/sim/app/api/settings/allowed-mcp-domains/route.ts index 207eb706165..54f98871588 100644 --- a/apps/sim/app/api/settings/allowed-mcp-domains/route.ts +++ b/apps/sim/app/api/settings/allowed-mcp-domains/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' -import { getAllowedMcpDomainsFromEnv } from '@/lib/core/config/feature-flags' +import { getAllowedMcpDomainsFromEnv } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/settings/allowed-providers/route.ts b/apps/sim/app/api/settings/allowed-providers/route.ts index 81b0b66b11c..7a4d5ce4d88 100644 --- a/apps/sim/app/api/settings/allowed-providers/route.ts +++ b/apps/sim/app/api/settings/allowed-providers/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' -import { getBlacklistedProvidersFromEnv } from '@/lib/core/config/feature-flags' +import { getBlacklistedProvidersFromEnv } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const GET = withRouteHandler(async () => { diff --git a/apps/sim/app/api/speech/token/route.test.ts b/apps/sim/app/api/speech/token/route.test.ts index a3181729831..8c00a1c7787 100644 --- a/apps/sim/app/api/speech/token/route.test.ts +++ b/apps/sim/app/api/speech/token/route.test.ts @@ -51,7 +51,7 @@ vi.mock('@/app/api/workflows/utils', () => ({ vi.mock('@/lib/core/config/env', () => ({ env: { ELEVENLABS_API_KEY: 'test-key' } })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: false, getCostMultiplier: () => 1, })) diff --git a/apps/sim/app/api/speech/token/route.ts b/apps/sim/app/api/speech/token/route.ts index 116cb5f822d..e9e7dfa631c 100644 --- a/apps/sim/app/api/speech/token/route.ts +++ b/apps/sim/app/api/speech/token/route.ts @@ -10,7 +10,7 @@ import { getSession } from '@/lib/auth' import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { recordUsage } from '@/lib/billing/core/usage-log' import { env } from '@/lib/core/config/env' -import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags' +import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/env-flags' import { RateLimiter } from '@/lib/core/rate-limiter' import { validateAuthToken } from '@/lib/core/security/deployment' import { getClientIp } from '@/lib/core/utils/request' diff --git a/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts b/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts index 9565725c8a6..353fb3b6d80 100644 --- a/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts @@ -33,7 +33,7 @@ vi.mock('@/lib/table/service', () => ({ releaseJobClaim: mockReleaseJobClaim, })) vi.mock('@/lib/table/delete-runner', () => ({ runTableDelete: mockRunTableDelete })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isTriggerDevEnabled() { return flags.triggerDev }, diff --git a/apps/sim/app/api/table/[tableId]/delete-async/route.ts b/apps/sim/app/api/table/[tableId]/delete-async/route.ts index 7dcd8c37676..48596feecf0 100644 --- a/apps/sim/app/api/table/[tableId]/delete-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/delete-async/route.ts @@ -4,7 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { deleteTableRowsAsyncContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.ts b/apps/sim/app/api/table/[tableId]/export-async/route.ts index 26ded9b6e1d..5b873ac8ebf 100644 --- a/apps/sim/app/api/table/[tableId]/export-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/export-async/route.ts @@ -4,7 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { exportTableAsyncContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/table/[tableId]/import-async/route.ts b/apps/sim/app/api/table/[tableId]/import-async/route.ts index f256bf5f35a..f4bf5406f20 100644 --- a/apps/sim/app/api/table/[tableId]/import-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/import-async/route.ts @@ -4,7 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { importIntoTableAsyncContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/table/import-async/route.ts b/apps/sim/app/api/table/import-async/route.ts index f10b822b6e3..0d5b6a418a6 100644 --- a/apps/sim/app/api/table/import-async/route.ts +++ b/apps/sim/app/api/table/import-async/route.ts @@ -4,7 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { importTableAsyncContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/telemetry/route.ts b/apps/sim/app/api/telemetry/route.ts index aed019188cc..b1a80962c4e 100644 --- a/apps/sim/app/api/telemetry/route.ts +++ b/apps/sim/app/api/telemetry/route.ts @@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { telemetryContract } from '@/lib/api/contracts/telemetry' import { parseRequest } from '@/lib/api/server' import { env } from '@/lib/core/config/env' -import { isProd } from '@/lib/core/config/feature-flags' +import { isProd } from '@/lib/core/config/env-flags' import { enforceIpRateLimit } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/tools/onepassword/utils.test.ts b/apps/sim/app/api/tools/onepassword/utils.test.ts index 504ed46b050..4c07ede1323 100644 --- a/apps/sim/app/api/tools/onepassword/utils.test.ts +++ b/apps/sim/app/api/tools/onepassword/utils.test.ts @@ -8,7 +8,7 @@ const { mockDnsLookup, hostedFlag } = vi.hoisted(() => ({ hostedFlag: { value: false }, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isHosted() { return hostedFlag.value }, diff --git a/apps/sim/app/api/tools/onepassword/utils.ts b/apps/sim/app/api/tools/onepassword/utils.ts index 4dcee716966..87c5e090da0 100644 --- a/apps/sim/app/api/tools/onepassword/utils.ts +++ b/apps/sim/app/api/tools/onepassword/utils.ts @@ -12,7 +12,7 @@ import type { import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import * as ipaddr from 'ipaddr.js' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import { isPrivateOrReservedIP, secureFetchWithPinnedIP, diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts index c19168b7a6f..69b773accf5 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts @@ -25,7 +25,7 @@ import { } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' import { getOrganizationBillingData } from '@/lib/billing/core/organization' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts index 6bb367feb26..0f25618fc2c 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts @@ -40,7 +40,7 @@ import { removeUserFromOrganization, WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR, } from '@/lib/billing/organizations/membership' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts index 5c4466d182d..99e6d2c791c 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts @@ -39,7 +39,7 @@ import { import { parseRequest } from '@/lib/api/server' import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization' import { addUserToOrganization } from '@/lib/billing/organizations/membership' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { diff --git a/apps/sim/app/api/v1/auth.ts b/apps/sim/app/api/v1/auth.ts index ce288dd6768..0f391889005 100644 --- a/apps/sim/app/api/v1/auth.ts +++ b/apps/sim/app/api/v1/auth.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' -import { isAuthDisabled } from '@/lib/core/config/feature-flags' +import { isAuthDisabled } from '@/lib/core/config/env-flags' const logger = createLogger('V1Auth') diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts index 160d55171e0..fae011a480d 100644 --- a/apps/sim/app/api/wand/route.ts +++ b/apps/sim/app/api/wand/route.ts @@ -14,7 +14,7 @@ import { import { recordUsage } from '@/lib/billing/core/usage-log' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import { env } from '@/lib/core/config/env' -import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags' +import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/env-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { enrichTableSchema } from '@/lib/table/llm/wand' diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts index 9c4333c4fec..5585d27656c 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -19,7 +19,7 @@ import { agentMailMessageSchema, webhookSvixHeadersSchema, } from '@/lib/api/contracts/webhooks' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { executeInboxTask } from '@/lib/mothership/inbox/executor' import type { AgentMailWebhookPayload, RejectionReason } from '@/lib/mothership/inbox/types' diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts index f5952ecfe77..c8f7d6299ee 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts @@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile' import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc' -import { isE2BDocEnabled } from '@/lib/core/config/feature-flags' +import { isE2BDocEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 8c20a3e8b41..82e6f107b77 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -5,7 +5,7 @@ import { BrandedLayout } from '@/components/branded-layout' import { PostHogProvider } from '@/app/_shell/providers/posthog-provider' import { generateBrandedMetadata, generateThemeCSS } from '@/ee/whitelabeling' import '@/app/_styles/globals.css' -import { isHosted, isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/feature-flags' +import { isHosted, isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/env-flags' import { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler' import { QueryProvider } from '@/app/_shell/providers/query-provider' import { SessionProvider } from '@/app/_shell/providers/session-provider' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index f04463bfd91..2cf9cbbfe13 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -2,7 +2,7 @@ import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' import { redirect } from 'next/navigation' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' import { prefetchGeneralSettings, prefetchSubscriptionData, prefetchUserProfile } from './prefetch' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index 80a4838f703..c191c2bd09b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -24,7 +24,7 @@ import { telemetryContract } from '@/lib/api/contracts/telemetry' import { signOut, useSession } from '@/lib/auth/auth-client' import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' import { getEnv, isTruthy } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import { handleKeyboardActivation } from '@/lib/core/utils/keyboard' import { getBaseUrl } from '@/lib/core/utils/urls' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx index 50c240a13cb..7d5afba2de9 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx @@ -13,7 +13,7 @@ import { DropdownMenuTrigger, Plus, } from '@/components/emcn' -import { isWorkflowColumnsEnabledClient } from '@/lib/core/config/feature-flags' +import { isWorkflowColumnsEnabledClient } from '@/lib/core/config/env-flags' import type { ColumnDefinition } from '@/lib/table' import { COLUMN_TYPE_OPTIONS } from '../column-config-sidebar' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 245f102ecb5..df2f2f0ff60 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -7,7 +7,7 @@ import { ChevronDown, ChipConfirmModal, chipVariants } from '@/components/emcn' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client' import { isEnterprise } from '@/lib/billing/plan-helpers' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import { cn } from '@/lib/core/utils/cn' import { getUserRole } from '@/lib/workspaces/organization' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index 3148e646732..85b2e273960 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -34,7 +34,7 @@ const { mockProviders } = vi.hoisted(() => ({ }, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isHosted() { return mockIsHosted.value }, diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 4b1d0b556ed..861c0b12de2 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -4,7 +4,7 @@ import { isCohereConfigured, isHosted, isOllamaConfigured, -} from '@/lib/core/config/feature-flags' +} from '@/lib/core/config/env-flags' import { getScopesForService } from '@/lib/oauth/utils' import { buildCanonicalIndex } from '@/lib/workflows/subblocks/visibility' import type { BlockOutput, OutputFieldDefinition, SubBlockConfig } from '@/blocks/types' diff --git a/apps/sim/components/emails/components/email-footer.tsx b/apps/sim/components/emails/components/email-footer.tsx index ee0a46209d0..610072b4d98 100644 --- a/apps/sim/components/emails/components/email-footer.tsx +++ b/apps/sim/components/emails/components/email-footer.tsx @@ -1,6 +1,6 @@ import { Container, Img, Link, Section } from '@react-email/components' import { baseStyles, colors, spacing, typography } from '@/components/emails/_styles' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' diff --git a/apps/sim/connectors/s3/s3.ts b/apps/sim/connectors/s3/s3.ts index 677427e25da..89ea546ae32 100644 --- a/apps/sim/connectors/s3/s3.ts +++ b/apps/sim/connectors/s3/s3.ts @@ -2,7 +2,7 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { S3Icon } from '@/components/icons' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import { secureFetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server' import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts index 5a2cf46bced..22ee24c8d69 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -69,7 +69,7 @@ vi.mock('@/lib/billing', () => ({ isWorkspaceOnEnterprisePlan: mockIsWorkspaceOnEnterprisePlan, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ getAllowedIntegrationsFromEnv: mockGetAllowedIntegrationsFromEnv, isAccessControlEnabled: true, isHosted: true, diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index 400682953fe..84f7c64f429 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -9,7 +9,7 @@ import { isHosted, isInvitationsDisabled, isPublicApiDisabled, -} from '@/lib/core/config/feature-flags' +} from '@/lib/core/config/env-flags' import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index 4e3bff54a35..5ac649517f8 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { Chip, ChipSelect, toast } from '@/components/emcn' import { useSession } from '@/lib/auth/auth-client' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { getUserRole } from '@/lib/workspaces/organization/utils' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { InfoNote } from '@/ee/components/info-note' diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx index e3840b7492c..048cd0a3c96 100644 --- a/apps/sim/ee/sso/components/sso-settings.tsx +++ b/apps/sim/ee/sso/components/sso-settings.tsx @@ -19,7 +19,7 @@ import { import type { SsoRegistrationBody } from '@/lib/api/contracts/auth' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client/utils' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { cn } from '@/lib/core/utils/cn' import { getBaseUrl } from '@/lib/core/utils/urls' import { getUserRole } from '@/lib/workspaces/organization/utils' diff --git a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx index f6cf33511d3..c10ccb1e89f 100644 --- a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx +++ b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx @@ -10,7 +10,7 @@ import { Button, ChipInput, Label, Loader, toast } from '@/components/emcn' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client/utils' import { HEX_COLOR_REGEX } from '@/lib/branding' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { cn } from '@/lib/core/utils/cn' import { getUserRole } from '@/lib/workspaces/organization/utils' import { diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 2d7a89c0a87..6990a719bf2 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -11,7 +11,7 @@ import { executeTool } from '@/tools' process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: false, isProd: false, isDev: true, diff --git a/apps/sim/hooks/queries/copilot-keys.ts b/apps/sim/hooks/queries/copilot-keys.ts index d20037df831..b78ba8153a0 100644 --- a/apps/sim/hooks/queries/copilot-keys.ts +++ b/apps/sim/hooks/queries/copilot-keys.ts @@ -8,7 +8,7 @@ import { generateCopilotApiKeyContract, listCopilotApiKeysContract, } from '@/lib/api/contracts' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' const logger = createLogger('CopilotKeysQuery') diff --git a/apps/sim/lib/a2a/push-notifications.ts b/apps/sim/lib/a2a/push-notifications.ts index 4413b569fbf..016e993c4ac 100644 --- a/apps/sim/lib/a2a/push-notifications.ts +++ b/apps/sim/lib/a2a/push-notifications.ts @@ -3,7 +3,7 @@ import { db } from '@sim/db' import { a2aPushNotificationConfig, a2aTask } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { secureFetchWithPinnedIP, validateUrlWithDNS, diff --git a/apps/sim/lib/analytics/profound.ts b/apps/sim/lib/analytics/profound.ts index eedcb955727..ff8c568e14d 100644 --- a/apps/sim/lib/analytics/profound.ts +++ b/apps/sim/lib/analytics/profound.ts @@ -7,7 +7,7 @@ */ import { createLogger } from '@sim/logger' import { env } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import { getClientIp } from '@/lib/core/utils/request' import { getBaseDomain } from '@/lib/core/utils/urls' diff --git a/apps/sim/lib/api-key/byok.test.ts b/apps/sim/lib/api-key/byok.test.ts index 65e0f80362d..6c1fcba13f0 100644 --- a/apps/sim/lib/api-key/byok.test.ts +++ b/apps/sim/lib/api-key/byok.test.ts @@ -35,7 +35,7 @@ vi.mock('@/lib/core/config/env', () => ({ env: {}, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: false, })) diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index b0b19a9a491..b131d2f7425 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger' import { and, asc, eq } from 'drizzle-orm' import { getRotatingApiKey } from '@/lib/core/config/api-keys' import { env } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import { decryptSecret } from '@/lib/core/security/encryption' import { getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { getHostedModels } from '@/providers/models' diff --git a/apps/sim/lib/auth/access-control.test.ts b/apps/sim/lib/auth/access-control.test.ts index 8e685d3f7ac..5667f724ca6 100644 --- a/apps/sim/lib/auth/access-control.test.ts +++ b/apps/sim/lib/auth/access-control.test.ts @@ -28,7 +28,7 @@ vi.mock('@/lib/core/config/env', () => ({ }, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isAppConfigEnabled() { return flagRef.isAppConfigEnabled }, diff --git a/apps/sim/lib/auth/access-control.ts b/apps/sim/lib/auth/access-control.ts index fe09a2b6737..9a4a46addb5 100644 --- a/apps/sim/lib/auth/access-control.ts +++ b/apps/sim/lib/auth/access-control.ts @@ -1,6 +1,6 @@ import { fetchAppConfigProfile } from '@/lib/core/config/appconfig' import { env } from '@/lib/core/config/env' -import { isAppConfigEnabled } from '@/lib/core/config/feature-flags' +import { isAppConfigEnabled } from '@/lib/core/config/env-flags' /** * Name of the AppConfig configuration profile holding the signup/login gating diff --git a/apps/sim/lib/auth/auth-client.ts b/apps/sim/lib/auth/auth-client.ts index 1a969c7cd19..b1ca36b84d2 100644 --- a/apps/sim/lib/auth/auth-client.ts +++ b/apps/sim/lib/auth/auth-client.ts @@ -11,7 +11,7 @@ import { import { createAuthClient } from 'better-auth/react' import type { auth } from '@/lib/auth' import { env } from '@/lib/core/config/env' -import { isBillingEnabled, isOrganizationsEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled, isOrganizationsEnabled } from '@/lib/core/config/env-flags' import { getBaseUrl, getBrowserOrigin } from '@/lib/core/utils/urls' import { SessionContext, type SessionHookResult } from '@/app/_shell/providers/session-provider' diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 099e14aa7a3..6b27254a950 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -75,7 +75,7 @@ import { isSignupEmailValidationEnabled, isSignupMxValidationEnabled, isSsoEnabled, -} from '@/lib/core/config/feature-flags' +} from '@/lib/core/config/env-flags' import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' import { processCredentialDraft } from '@/lib/credentials/draft-processor' diff --git a/apps/sim/lib/auth/ban.test.ts b/apps/sim/lib/auth/ban.test.ts index f6e53aa9bb0..7a38b914294 100644 --- a/apps/sim/lib/auth/ban.test.ts +++ b/apps/sim/lib/auth/ban.test.ts @@ -22,7 +22,7 @@ vi.mock('@/lib/core/config/env', () => ({ return envRef }, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ isAppConfigEnabled: false })) +vi.mock('@/lib/core/config/env-flags', () => ({ isAppConfigEnabled: false })) import { getActivelyBannedUserIds, isBanActive, isEmailBlocked } from '@/lib/auth/ban' diff --git a/apps/sim/lib/billing/calculations/usage-monitor.test.ts b/apps/sim/lib/billing/calculations/usage-monitor.test.ts index d78d984d9ad..b59dd045a13 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.test.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.test.ts @@ -11,7 +11,7 @@ const { mockFlags, mockDbLimit, mockGetOrgMemberUsageLimit, mockGetOrgMemberWork mockGetOrgMemberWorkspaceUsage: vi.fn(), })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isHosted() { return mockFlags.isHosted }, diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index df6e7d7fda3..906007689f8 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -22,7 +22,7 @@ import { import { getPlanTierDollars, isPaid } from '@/lib/billing/plan-helpers' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' -import { isBillingEnabled, isHosted } from '@/lib/core/config/feature-flags' +import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' const logger = createLogger('UsageMonitor') diff --git a/apps/sim/lib/billing/calculations/usage-reservation.test.ts b/apps/sim/lib/billing/calculations/usage-reservation.test.ts index a6435aff69c..11a93055003 100644 --- a/apps/sim/lib/billing/calculations/usage-reservation.test.ts +++ b/apps/sim/lib/billing/calculations/usage-reservation.test.ts @@ -8,7 +8,7 @@ const { mockFlags } = vi.hoisted(() => ({ mockFlags: { isBillingEnabled: true }, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isBillingEnabled() { return mockFlags.isBillingEnabled }, diff --git a/apps/sim/lib/billing/calculations/usage-reservation.ts b/apps/sim/lib/billing/calculations/usage-reservation.ts index c448c88776d..a66e9e43f7f 100644 --- a/apps/sim/lib/billing/calculations/usage-reservation.ts +++ b/apps/sim/lib/billing/calculations/usage-reservation.ts @@ -3,7 +3,7 @@ import { toError } from '@sim/utils/errors' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { getRedisClient } from '@/lib/core/config/redis' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types' diff --git a/apps/sim/lib/billing/core/subscription.test.ts b/apps/sim/lib/billing/core/subscription.test.ts index 2f49aa6ba25..4d25f9ec426 100644 --- a/apps/sim/lib/billing/core/subscription.test.ts +++ b/apps/sim/lib/billing/core/subscription.test.ts @@ -30,7 +30,7 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({ USABLE_SUBSCRIPTION_STATUSES: ['active', 'trialing'], })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ isAccessControlEnabled: false, isBillingEnabled: true, isCredentialSetsEnabled: false, diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 550fdcd3400..ca0996eabc6 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -27,7 +27,7 @@ import { isHosted, isInboxEnabled, isSsoEnabled, -} from '@/lib/core/config/feature-flags' +} from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' const logger = createLogger('SubscriptionCore') diff --git a/apps/sim/lib/billing/core/usage-log.test.ts b/apps/sim/lib/billing/core/usage-log.test.ts index ff2f446c832..889cb3662f7 100644 --- a/apps/sim/lib/billing/core/usage-log.test.ts +++ b/apps/sim/lib/billing/core/usage-log.test.ts @@ -57,7 +57,7 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({ isOrgScopedSubscription: mockIsOrgScopedSubscription, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true, })) diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 3d1fcf04be2..ef3cc7d7631 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -32,7 +32,7 @@ import { } from '@/lib/billing/subscriptions/utils' import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types' import { Decimal, toDecimal, toNumber } from '@/lib/billing/utils/decimal' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import type { DbClient } from '@/lib/db/types' import { sendEmail } from '@/lib/messaging/email/mailer' diff --git a/apps/sim/lib/billing/organizations/seat-drift.test.ts b/apps/sim/lib/billing/organizations/seat-drift.test.ts index 63d319effa3..b3b2de307b4 100644 --- a/apps/sim/lib/billing/organizations/seat-drift.test.ts +++ b/apps/sim/lib/billing/organizations/seat-drift.test.ts @@ -27,7 +27,7 @@ vi.mock('@/lib/billing/organizations/seats', () => ({ reconcileOrganizationSeats: mockReconcileOrganizationSeats, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isBillingEnabled() { return mockFeatureFlags.isBillingEnabled }, diff --git a/apps/sim/lib/billing/organizations/seat-drift.ts b/apps/sim/lib/billing/organizations/seat-drift.ts index 18819ac6455..f4f06d1a9f5 100644 --- a/apps/sim/lib/billing/organizations/seat-drift.ts +++ b/apps/sim/lib/billing/organizations/seat-drift.ts @@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq, inArray, isNotNull, like, or, sql } from 'drizzle-orm' import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' const logger = createLogger('SeatDriftSweep') diff --git a/apps/sim/lib/billing/organizations/seats.test.ts b/apps/sim/lib/billing/organizations/seats.test.ts index 95725b94667..da1d27aef90 100644 --- a/apps/sim/lib/billing/organizations/seats.test.ts +++ b/apps/sim/lib/billing/organizations/seats.test.ts @@ -52,7 +52,7 @@ vi.mock('@/lib/billing/webhooks/outbox-handlers', () => ({ }, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isBillingEnabled() { return mockFeatureFlags.isBillingEnabled }, diff --git a/apps/sim/lib/billing/organizations/seats.ts b/apps/sim/lib/billing/organizations/seats.ts index ac2b85f8d92..a956a38d444 100644 --- a/apps/sim/lib/billing/organizations/seats.ts +++ b/apps/sim/lib/billing/organizations/seats.ts @@ -6,7 +6,7 @@ import { syncSubscriptionUsageLimits } from '@/lib/billing/organization' import { isTeam } from '@/lib/billing/plan-helpers' import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' const logger = createLogger('OrganizationSeats') diff --git a/apps/sim/lib/billing/storage/limits.ts b/apps/sim/lib/billing/storage/limits.ts index 7cfb6b19ff6..6c9c96d4208 100644 --- a/apps/sim/lib/billing/storage/limits.ts +++ b/apps/sim/lib/billing/storage/limits.ts @@ -16,7 +16,7 @@ import { eq } from 'drizzle-orm' import { getPlanTypeForLimits, isEnterprise, isFree } from '@/lib/billing/plan-helpers' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { getEnv } from '@/lib/core/config/env' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' const logger = createLogger('StorageLimits') diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts index 8fb3d962efb..5e838d76368 100644 --- a/apps/sim/lib/billing/storage/tracking.ts +++ b/apps/sim/lib/billing/storage/tracking.ts @@ -9,7 +9,7 @@ import { organization, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq, sql } from 'drizzle-orm' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' const logger = createLogger('StorageTracking') diff --git a/apps/sim/lib/billing/validation/seat-management.test.ts b/apps/sim/lib/billing/validation/seat-management.test.ts index c99cd09e26b..cbab22d9898 100644 --- a/apps/sim/lib/billing/validation/seat-management.test.ts +++ b/apps/sim/lib/billing/validation/seat-management.test.ts @@ -37,7 +37,7 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({ getEffectiveSeats: vi.fn().mockReturnValue(10), })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isBillingEnabled() { return mockFeatureFlags.isBillingEnabled }, diff --git a/apps/sim/lib/billing/validation/seat-management.ts b/apps/sim/lib/billing/validation/seat-management.ts index 48bb573633d..f9f671ddc1a 100644 --- a/apps/sim/lib/billing/validation/seat-management.ts +++ b/apps/sim/lib/billing/validation/seat-management.ts @@ -6,7 +6,7 @@ import { getOrganizationSubscription } from '@/lib/billing/core/billing' import { isEnterprise, isFree } from '@/lib/billing/plan-helpers' import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils' import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { hasInflightOutboxEvent } from '@/lib/core/outbox/service' import { quickValidateEmail } from '@/lib/messaging/email/validation' diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index a1f92a08195..1afbfbbaac3 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { featureFlagsMock, workflowsUtilsMock } from '@sim/testing' +import { envFlagsMock, workflowsUtilsMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCreateUserToolSchema, mockGetHighestPrioritySubscription } = vi.hoisted(() => ({ @@ -19,7 +19,7 @@ vi.mock('@/lib/billing/plan-helpers', () => ({ ), })) -vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) vi.mock('@/lib/mcp/utils', () => ({ createMcpToolId: vi.fn(), diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index 31da4396bc1..84168f860f2 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -7,7 +7,7 @@ import { getExposedIntegrationTools } from '@/lib/copilot/integration-tools' import { getToolEntry } from '@/lib/copilot/tool-executor/router' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' -import { isE2BDocEnabled, isHosted } from '@/lib/core/config/feature-flags' +import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags' import { buildUserSkillTool } from '@/lib/mothership/skills' import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { stripVersionSuffix } from '@/tools/utils' diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index cc930889f13..81f4eae47c2 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -16,7 +16,7 @@ import { encodeVfsPathSegments, encodeVfsSegment, } from '@/lib/copilot/vfs/path-utils' -import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { getTableById } from '@/lib/table/service' import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 3df97818c0e..7c46a3cb10d 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' import { isPlanAliasPath, workflowAliasSandboxPath } from '@/lib/copilot/vfs/workflow-aliases' -import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/feature-flags' +import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/env-flags' import { getTableById, listTables, queryRows } from '@/lib/table/service' import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index 1e1e5c30332..f2fc3846ada 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -5,7 +5,7 @@ import { toError } from '@sim/utils/errors' import { z } from 'zod' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { getAllowedIntegrationsFromEnv, isHosted } from '@/lib/core/config/feature-flags' +import { getAllowedIntegrationsFromEnv, isHosted } from '@/lib/core/config/env-flags' import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' import { getBlock } from '@/blocks/registry' import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types' diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts index 0227357908f..6a12632149b 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { z } from 'zod' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { getAllBlocks } from '@/blocks/registry' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts index 920830f193b..45604e96676 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/feature-flags' +import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/env-flags' import { executeInE2B, executeShellInE2B, type SandboxFile } from '@/lib/execution/e2b' import { CodeLanguage } from '@/lib/execution/languages' import { diff --git a/apps/sim/lib/copilot/tools/server/files/edit-content.ts b/apps/sim/lib/copilot/tools/server/files/edit-content.ts index e272146045a..35b2f599add 100644 --- a/apps/sim/lib/copilot/tools/server/files/edit-content.ts +++ b/apps/sim/lib/copilot/tools/server/files/edit-content.ts @@ -5,7 +5,7 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { isE2BDocEnabled } from '@/lib/core/config/feature-flags' +import { isE2BDocEnabled } from '@/lib/core/config/env-flags' import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getE2BDocFormat } from './doc-compile' import { consumeLatestFileIntent } from './file-intent-store' diff --git a/apps/sim/lib/copilot/tools/server/files/touch-plan.test.ts b/apps/sim/lib/copilot/tools/server/files/touch-plan.test.ts index 3134cb2c9fd..75cf6f62b05 100644 --- a/apps/sim/lib/copilot/tools/server/files/touch-plan.test.ts +++ b/apps/sim/lib/copilot/tools/server/files/touch-plan.test.ts @@ -18,7 +18,7 @@ vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ writeWorkspaceFileByPath: mocks.writeWorkspaceFileByPath, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ isMothershipBetaFeaturesEnabled: true, })) diff --git a/apps/sim/lib/copilot/tools/server/files/touch-plan.ts b/apps/sim/lib/copilot/tools/server/files/touch-plan.ts index 168903236ba..416a1529628 100644 --- a/apps/sim/lib/copilot/tools/server/files/touch-plan.ts +++ b/apps/sim/lib/copilot/tools/server/files/touch-plan.ts @@ -12,7 +12,7 @@ import { } from '@/lib/copilot/vfs/path-utils' import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' -import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/feature-flags' +import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/env-flags' const logger = createLogger('TouchPlanServerTool') const TOUCH_PLAN_TOOL_ID = 'touch_plan' diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 2ebc7319048..77f5a71e67c 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -11,7 +11,7 @@ import { import { ensureWorkflowAliasBacking } from '@/lib/copilot/vfs/workflow-alias-backing' import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' import { isPlanAliasPath } from '@/lib/copilot/vfs/workflow-aliases' -import { isE2BDocEnabled } from '@/lib/core/config/feature-flags' +import { isE2BDocEnabled } from '@/lib/core/config/env-flags' import { runSandboxTask } from '@/lib/execution/sandbox/run-task' import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index a151b25a511..029f0fce660 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -59,7 +59,7 @@ import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-cr import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables' import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow' import { queryLogsServerTool } from '@/lib/copilot/tools/server/workflow/query-logs' -import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/feature-flags' +import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/env-flags' export type ExecuteResponseSuccess = z.output diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index 5493727f72d..9c929988030 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { featureFlagsMock } from '@sim/testing' +import { envFlagsMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { normalizeConditionRouterIds } from './builders' @@ -92,7 +92,7 @@ vi.mock('@/lib/copilot/validation/selector-validator', () => ({ validateSelectorIds: mockValidateSelectorIds, })) -vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) vi.mock('@/providers/utils', () => ({ getHostedModels: () => [], diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index bce551f32b6..f881482fa70 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -945,7 +945,7 @@ export async function preValidateCredentialInputs( context: { userId: string; workspaceId?: string }, workflowState?: Record ): Promise<{ filteredOperations: EditWorkflowOperation[]; errors: ValidationError[] }> { - const { isHosted } = await import('@/lib/core/config/feature-flags') + const { isHosted } = await import('@/lib/core/config/env-flags') const { getHostedModels } = await import('@/providers/utils') const logger = createLogger('PreValidateCredentials') diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 984f341978c..b5a8701421d 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -1,6 +1,6 @@ import { truncate } from '@sim/utils/string' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import { DYNAMIC_MODEL_PROVIDERS, PROVIDER_DEFINITIONS } from '@/providers/models' diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts b/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts index aebfb6baa8a..4e062a51fc2 100644 --- a/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts +++ b/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts @@ -8,7 +8,7 @@ import { resolveWorkspacePlanAliasPath, type WorkflowAliasTarget, } from '@/lib/copilot/vfs/workflow-aliases' -import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/feature-flags' +import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/env-flags' import { canonicalizeVfsPath } from './path-utils' export async function resolveWorkflowAliasForWorkspace(args: { diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 8f710b75130..c1b4fb9cb35 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -88,7 +88,7 @@ import { workspacePlanBackingPath, workspacePlansBackingFolderPath, } from '@/lib/copilot/vfs/workflow-aliases' -import { isE2BDocEnabled, isMothershipBetaFeaturesEnabled } from '@/lib/core/config/feature-flags' +import { isE2BDocEnabled, isMothershipBetaFeaturesEnabled } from '@/lib/core/config/env-flags' import { getAccessibleEnvCredentials, getAccessibleOAuthCredentials, diff --git a/apps/sim/lib/core/async-jobs/config.ts b/apps/sim/lib/core/async-jobs/config.ts index 5d32dc6fcd8..7f1e3797b34 100644 --- a/apps/sim/lib/core/async-jobs/config.ts +++ b/apps/sim/lib/core/async-jobs/config.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { taskContext } from '@trigger.dev/core/v3' import type { AsyncBackendType, JobQueueBackend } from '@/lib/core/async-jobs/types' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' const logger = createLogger('AsyncJobsConfig') diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts new file mode 100644 index 00000000000..4374db7ab8c --- /dev/null +++ b/apps/sim/lib/core/config/env-flags.ts @@ -0,0 +1,318 @@ +/** + * Environment utility functions for consistent environment detection across the application + */ +import { env, getEnv, isFalsy, isTruthy } from './env' + +/** + * Is the application running in production mode + */ +export const isProd = env.NODE_ENV === 'production' + +/** + * Is the application running in development mode + */ +export const isDev = env.NODE_ENV === 'development' + +/** + * Is the application running in test mode + */ +export const isTest = env.NODE_ENV === 'test' + +/** + * Is this the hosted version of the application. + * True for sim.ai and any subdomain of sim.ai (e.g. staging.sim.ai, dev.sim.ai). + */ +const appUrl = getEnv('NEXT_PUBLIC_APP_URL') +let appHostname = '' +try { + appHostname = appUrl ? new URL(appUrl).hostname : '' +} catch { + // invalid URL — isHosted stays false +} +export const isHosted = appHostname === 'sim.ai' || appHostname.endsWith('.sim.ai') + +/** + * Is billing enforcement enabled + */ +export const isBillingEnabled = isTruthy(env.BILLING_ENABLED) + +/** + * Order table rows by fractional `order_key` (O(1) insert/delete) instead of the + * legacy integer `position`. When off, behavior is unchanged. Keys are written + * regardless of this flag; it only controls which column is authoritative for + * reads/ordering and whether inserts/deletes reshift positions. + */ +export const isTablesFractionalOrderingEnabled = isTruthy(env.TABLES_FRACTIONAL_ORDERING) + +/** + * Is email verification enabled + */ +export const isEmailVerificationEnabled = isTruthy(env.EMAIL_VERIFICATION_ENABLED) + +/** + * Is authentication disabled (for self-hosted deployments behind private networks) + * This flag is blocked when isHosted is true. + */ +export const isAuthDisabled = isTruthy(env.DISABLE_AUTH) && !isHosted + +if (isTruthy(env.DISABLE_AUTH)) { + import('@sim/logger') + .then(({ createLogger }) => { + const logger = createLogger('EnvFlags') + if (isHosted) { + logger.error( + 'DISABLE_AUTH is set but ignored on hosted environment. Authentication remains enabled for security.' + ) + } else { + logger.warn( + 'DISABLE_AUTH is enabled. Authentication is bypassed and all requests use an anonymous session. Only use this in trusted private networks.' + ) + } + }) + .catch(() => { + // Fallback during config compilation when logger is unavailable + }) +} + +/** + * Is user registration disabled + */ +export const isRegistrationDisabled = isTruthy(env.DISABLE_REGISTRATION) + +/** + * Is email/password authentication enabled (defaults to true) + */ +export const isEmailPasswordEnabled = !isFalsy(env.EMAIL_PASSWORD_SIGNUP_ENABLED) + +/** + * Is signup email validation enabled (disposable email blocking via better-auth-harmony) + */ +export const isSignupEmailValidationEnabled = isTruthy(env.SIGNUP_EMAIL_VALIDATION_ENABLED) + +/** + * Is MX-based signup validation enabled (blocks no-MX domains and denylisted shared spam + * mail backends). Opt-in to avoid adding a DNS dependency or blocking legitimate signups on + * self-hosted deployments with non-standard mail setups; enable on abuse-targeted deployments. + */ +export const isSignupMxValidationEnabled = isTruthy(env.SIGNUP_MX_VALIDATION_ENABLED) + +/** + * Is AWS AppConfig the source of truth for the signup/login gating lists. + * Hosted-only and requires both AppConfig identifiers (injected by the infra + * stack). Self-hosted/OSS deployments always use the env-var fallback, so the + * AppConfig client is never reached off-hosted. + */ +export const isAppConfigEnabled = + isHosted && Boolean(env.APPCONFIG_APPLICATION && env.APPCONFIG_ENVIRONMENT) + +/** + * Is Trigger.dev enabled for async job processing + */ +export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED) + +/** + * Is SSO enabled for enterprise authentication + */ +export const isSsoEnabled = isTruthy(env.SSO_ENABLED) + +/** + * Is credential sets (email polling) enabled via env var override + * This bypasses plan requirements for self-hosted deployments + */ +export const isCredentialSetsEnabled = isTruthy(env.CREDENTIAL_SETS_ENABLED) + +/** + * Is access control (permission groups) enabled via env var override + * This bypasses plan requirements for self-hosted deployments + */ +export const isAccessControlEnabled = isTruthy(env.ACCESS_CONTROL_ENABLED) + +/** + * Is organizations enabled + * True if billing is enabled (orgs come with billing), OR explicitly enabled via env var, + * OR if access control is enabled (access control requires organizations) + */ +export const isOrganizationsEnabled = + isBillingEnabled || isTruthy(env.ORGANIZATIONS_ENABLED) || isAccessControlEnabled + +/** + * Is inbox (Sim Mailer) enabled via env var override + * This bypasses hosted requirements for self-hosted deployments + */ +export const isInboxEnabled = isTruthy(env.INBOX_ENABLED) + +/** + * Is whitelabeling enabled via env var override + * This bypasses hosted requirements for self-hosted deployments + */ +export const isWhitelabelingEnabled = isTruthy(env.WHITELABELING_ENABLED) + +/** + * Is audit logs enabled via env var override + * This bypasses hosted requirements for self-hosted deployments + */ +export const isAuditLogsEnabled = isTruthy(env.AUDIT_LOGS_ENABLED) + +/** + * Is data retention enabled via env var override + * This bypasses hosted requirements for self-hosted deployments + */ +export const isDataRetentionEnabled = isTruthy(env.DATA_RETENTION_ENABLED) + +/** + * Is data drains enabled via env var override + * This bypasses hosted requirements for self-hosted deployments + */ +export const isDataDrainsEnabled = isTruthy(env.DATA_DRAINS_ENABLED) + +/** + * Are workflow output columns enabled in user tables. + * Defaults to false; set NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED=true to show + * the "Workflow" column type in the new-column dropdown. + */ +export const isWorkflowColumnsEnabledClient = isTruthy( + getEnv('NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED') +) + +/** + * Enables beta Mothership plan/changelog artifact surfaces. + */ +export const isMothershipBetaFeaturesEnabled = isTruthy(env.MOTHERSHIP_BETA_FEATURES) + +/** + * Is E2B enabled for remote code execution + */ +export const isE2bEnabled = isTruthy(env.E2B_ENABLED) + +/** + * Whether the E2B document-generation sandbox is enabled. + * + * Requires E2B (with an API key) AND a dedicated doc-generation template id. + * When true, ALL four formats compile in the E2B doc sandbox: pptx/docx via Node + * (pptxgenjs/docx + react-icons/sharp icons), pdf/xlsx via Python + * (reportlab/openpyxl). When false, compilation stays on the JavaScript + * (isolated-vm) path, byte-identical to its prior behavior (and xlsx is + * unavailable). Drives both the Sim compile backend and the `docCompiler` flag + * sent to the copilot file subagent so the agent's output and compiler agree. + */ +export const isE2BDocEnabled = + isE2bEnabled && Boolean(env.E2B_API_KEY) && Boolean(env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID) + +/** + * Whether Ollama is configured (OLLAMA_URL is set). + * When true, models that are not in the static cloud model list and have no + * slash-prefixed provider namespace are assumed to be Ollama models + * and do not require an API key. + */ +export const isOllamaConfigured = Boolean(env.OLLAMA_URL) + +/** + * Whether Azure OpenAI / Azure Anthropic credentials are pre-configured at the server level + * (via AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, AZURE_ANTHROPIC_ENDPOINT, etc.). + * When true, the endpoint, API key, and API version fields are hidden in the Agent block UI. + * Set NEXT_PUBLIC_AZURE_CONFIGURED=true in self-hosted deployments on Azure. + */ +export const isAzureConfigured = isTruthy(getEnv('NEXT_PUBLIC_AZURE_CONFIGURED')) + +/** + * Whether a Cohere API key is pre-configured server-side for the Knowledge block reranker + * (`COHERE_API_KEY` or `COHERE_API_KEY_1/2/3`). When true, the Cohere API Key field is hidden + * in the Knowledge block UI. + * Set NEXT_PUBLIC_COHERE_CONFIGURED=true in self-hosted deployments that ship a Cohere key. + */ +export const isCohereConfigured = isTruthy(getEnv('NEXT_PUBLIC_COHERE_CONFIGURED')) + +/** + * Are invitations disabled globally + * When true, workspace invitations are disabled for all users + */ +export const isInvitationsDisabled = isTruthy(env.DISABLE_INVITATIONS) + +/** + * Is public API access disabled globally + * When true, the public API toggle is hidden and public API access is blocked + */ +export const isPublicApiDisabled = isTruthy(env.DISABLE_PUBLIC_API) + +/** + * Is Google OAuth login disabled + * When true, the Google OAuth login button is hidden even when credentials are configured + */ +export const isGoogleAuthDisabled = isTruthy(env.DISABLE_GOOGLE_AUTH) + +/** + * Is GitHub OAuth login disabled + * When true, the GitHub OAuth login button is hidden even when credentials are configured + */ +export const isGithubAuthDisabled = isTruthy(env.DISABLE_GITHUB_AUTH) + +/** + * Is React Grab enabled for UI element debugging + * When true and in development mode, enables React Grab for copying UI element context to clipboard + */ +export const isReactGrabEnabled = isDev && isTruthy(env.REACT_GRAB_ENABLED) + +/** + * Is React Scan enabled for performance debugging + * When true and in development mode, enables React Scan for detecting render performance issues + */ +export const isReactScanEnabled = isDev && isTruthy(env.REACT_SCAN_ENABLED) + +/** + * Returns the parsed allowlist of integration block types from the environment variable. + * If not set or empty, returns null (meaning all integrations are allowed). + */ +export function getAllowedIntegrationsFromEnv(): string[] | null { + if (!env.ALLOWED_INTEGRATIONS) return null + const parsed = env.ALLOWED_INTEGRATIONS.split(',') + .map((i) => i.trim().toLowerCase()) + .filter(Boolean) + return parsed.length > 0 ? parsed : null +} + +/** + * Returns the list of blacklisted provider IDs from the environment variable. + * If not set or empty, returns an empty array (meaning no providers are blacklisted). + */ +export function getBlacklistedProvidersFromEnv(): string[] { + if (!env.BLACKLISTED_PROVIDERS) return [] + return env.BLACKLISTED_PROVIDERS.split(',') + .map((p) => p.trim().toLowerCase()) + .filter(Boolean) +} + +/** + * Normalizes a domain entry from the ALLOWED_MCP_DOMAINS env var. + * Accepts bare hostnames (e.g., "mcp.company.com") or full URLs (e.g., "https://mcp.company.com"). + * Extracts the hostname in either case. + */ +function normalizeDomainEntry(entry: string): string { + const trimmed = entry.trim().toLowerCase() + if (!trimmed) return '' + if (trimmed.includes('://')) { + try { + return new URL(trimmed).hostname + } catch { + return trimmed + } + } + return trimmed +} + +/** + * Get allowed MCP server domains from the ALLOWED_MCP_DOMAINS env var. + * Returns null if not set (all domains allowed), or parsed array of lowercase hostnames. + * Accepts both bare hostnames and full URLs in the env var value. + */ +export function getAllowedMcpDomainsFromEnv(): string[] | null { + if (!env.ALLOWED_MCP_DOMAINS) return null + const parsed = env.ALLOWED_MCP_DOMAINS.split(',').map(normalizeDomainEntry).filter(Boolean) + return parsed.length > 0 ? parsed : null +} + +/** + * Get cost multiplier based on environment + */ +export function getCostMultiplier(): number { + return isProd ? (env.COST_MULTIPLIER ?? 1) : 1 +} diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts new file mode 100644 index 00000000000..2592d3c0e78 --- /dev/null +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -0,0 +1,156 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { FeatureFlagsConfig } from '@/lib/core/config/feature-flags' + +const { mockFetch, mockIsPlatformAdmin, envRef, flagRef } = vi.hoisted(() => ({ + mockFetch: vi.fn(), + mockIsPlatformAdmin: vi.fn(), + envRef: { + APPCONFIG_APPLICATION: 'sim-staging' as string | undefined, + APPCONFIG_ENVIRONMENT: 'staging' as string | undefined, + }, + flagRef: { isAppConfigEnabled: false }, +})) + +vi.mock('@/lib/core/config/appconfig', () => ({ + fetchAppConfigProfile: mockFetch, +})) + +vi.mock('@/lib/core/config/env', () => ({ + get env() { + return envRef + }, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + get isAppConfigEnabled() { + return flagRef.isAppConfigEnabled + }, +})) + +vi.mock('@/lib/permissions/super-user', () => ({ + isPlatformAdmin: mockIsPlatformAdmin, +})) + +import { getFeatureFlags, isFeatureEnabled } from '@/lib/core/config/feature-flags' + +/** Make `getFeatureFlags` resolve to `doc` via the AppConfig path (also exercises parseConfig). */ +function withAppConfig(doc: unknown) { + flagRef.isAppConfigEnabled = true + mockFetch.mockImplementation((_ids, parse) => Promise.resolve(parse(doc))) +} + +describe('getFeatureFlags', () => { + beforeEach(() => { + vi.clearAllMocks() + flagRef.isAppConfigEnabled = false + }) + + it('returns the in-file default (empty) when AppConfig is disabled, without fetching', async () => { + expect(await getFeatureFlags()).toEqual({ flags: {} }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('reads the feature-flags profile and normalizes the payload when enabled', async () => { + withAppConfig({ + flags: { + a: { enabled: true }, + b: { orgIds: ['Org_1', ' org_1 ', '', 'org_2'], userIds: 'nope' }, + c: 'not-an-object', + }, + }) + + const { flags } = await getFeatureFlags() + expect(flags.a).toEqual({ enabled: true }) + expect(flags.b).toEqual({ orgIds: ['Org_1', 'org_1', 'org_2'] }) + expect(flags.c).toBeUndefined() + expect(mockFetch).toHaveBeenCalledWith( + { application: 'sim-staging', environment: 'staging', profile: 'feature-flags' }, + expect.any(Function) + ) + }) + + it('falls back to the in-file default when the fetch yields null', async () => { + flagRef.isAppConfigEnabled = true + mockFetch.mockResolvedValue(null) + expect(await getFeatureFlags()).toEqual({ flags: {} }) + }) + + it('degrades gracefully on a malformed document', async () => { + withAppConfig({ flags: 'not-an-object' }) + expect(await getFeatureFlags()).toEqual({ flags: {} }) + withAppConfig(null) + expect(await getFeatureFlags()).toEqual({ flags: {} }) + }) +}) + +describe('isFeatureEnabled', () => { + beforeEach(() => { + vi.clearAllMocks() + flagRef.isAppConfigEnabled = false + }) + + it('returns false for an unknown flag', async () => { + withAppConfig({ flags: {} }) + expect(await isFeatureEnabled('missing', { userId: 'u1' })).toBe(false) + }) + + it('matches the global enabled clause', async () => { + withAppConfig({ flags: { f: { enabled: true } } }) + expect(await isFeatureEnabled('f')).toBe(true) + }) + + it('matches the userId allowlist', async () => { + withAppConfig({ flags: { f: { userIds: ['u1'] } } }) + expect(await isFeatureEnabled('f', { userId: 'u1' })).toBe(true) + expect(await isFeatureEnabled('f', { userId: 'u2' })).toBe(false) + expect(await isFeatureEnabled('f', {})).toBe(false) + }) + + it('matches the orgId allowlist', async () => { + withAppConfig({ flags: { f: { orgIds: ['o1'] } } }) + expect(await isFeatureEnabled('f', { orgId: 'o1' })).toBe(true) + expect(await isFeatureEnabled('f', { orgId: 'o2' })).toBe(false) + }) + + describe('admin clause (lazy resolution)', () => { + it('resolves admin from userId when admins is the deciding clause', async () => { + withAppConfig({ flags: { f: { admins: true } } }) + mockIsPlatformAdmin.mockResolvedValue(true) + expect(await isFeatureEnabled('f', { userId: 'u1' })).toBe(true) + expect(mockIsPlatformAdmin).toHaveBeenCalledWith('u1') + + mockIsPlatformAdmin.mockResolvedValue(false) + expect(await isFeatureEnabled('f', { userId: 'u2' })).toBe(false) + }) + + it('uses the isAdmin override without querying', async () => { + withAppConfig({ flags: { f: { admins: true } } }) + expect(await isFeatureEnabled('f', { userId: 'u1', isAdmin: true })).toBe(true) + expect(mockIsPlatformAdmin).not.toHaveBeenCalled() + }) + + it('resolves to false without querying when userId is absent', async () => { + withAppConfig({ flags: { f: { admins: true } } }) + expect(await isFeatureEnabled('f', { orgId: 'o1' })).toBe(false) + expect(mockIsPlatformAdmin).not.toHaveBeenCalled() + }) + + it('does not query when an earlier clause already matched', async () => { + withAppConfig({ flags: { f: { enabled: true, admins: true } } }) + expect(await isFeatureEnabled('f', { userId: 'u1' })).toBe(true) + + withAppConfig({ flags: { g: { userIds: ['u1'], admins: true } } }) + expect(await isFeatureEnabled('g', { userId: 'u1' })).toBe(true) + expect(mockIsPlatformAdmin).not.toHaveBeenCalled() + }) + + it('does not query when the rule has no admins clause', async () => { + withAppConfig({ flags: { f: { userIds: ['u2'] } } }) + expect(await isFeatureEnabled('f', { userId: 'u1' })).toBe(false) + expect(mockIsPlatformAdmin).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 45374727689..d83ffe252fd 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -1,318 +1,142 @@ -/** - * Environment utility functions for consistent environment detection across the application - */ -import { env, getEnv, isFalsy, isTruthy } from './env' - -/** - * Is the application running in production mode - */ -export const isProd = env.NODE_ENV === 'production' - -/** - * Is the application running in development mode - */ -export const isDev = env.NODE_ENV === 'development' +import { fetchAppConfigProfile } from '@/lib/core/config/appconfig' +import { env } from '@/lib/core/config/env' +import { isAppConfigEnabled } from '@/lib/core/config/env-flags' /** - * Is the application running in test mode + * Name of the AppConfig configuration profile holding the gated feature flags. + * Cross-repo contract: must match the `CfnConfigurationProfile` name created by + * the infra stack. */ -export const isTest = env.NODE_ENV === 'test' +const FEATURE_FLAGS_PROFILE = 'feature-flags' /** - * Is this the hosted version of the application. - * True for sim.ai and any subdomain of sim.ai (e.g. staging.sim.ai, dev.sim.ai). + * A single flag's gating rule. A flag is ON for a context when ANY clause matches: + * the global `enabled` default, the org/user allowlists, or `admins` for platform + * admins. An absent clause never matches. */ -const appUrl = getEnv('NEXT_PUBLIC_APP_URL') -let appHostname = '' -try { - appHostname = appUrl ? new URL(appUrl).hostname : '' -} catch { - // invalid URL — isHosted stays false +export interface FeatureFlagRule { + enabled?: boolean + orgIds?: string[] + userIds?: string[] + admins?: boolean } -export const isHosted = appHostname === 'sim.ai' || appHostname.endsWith('.sim.ai') - -/** - * Is billing enforcement enabled - */ -export const isBillingEnabled = isTruthy(env.BILLING_ENABLED) - -/** - * Order table rows by fractional `order_key` (O(1) insert/delete) instead of the - * legacy integer `position`. When off, behavior is unchanged. Keys are written - * regardless of this flag; it only controls which column is authoritative for - * reads/ordering and whether inserts/deletes reshift positions. - */ -export const isTablesFractionalOrderingEnabled = isTruthy(env.TABLES_FRACTIONAL_ORDERING) -/** - * Is email verification enabled - */ -export const isEmailVerificationEnabled = isTruthy(env.EMAIL_VERIFICATION_ENABLED) - -/** - * Is authentication disabled (for self-hosted deployments behind private networks) - * This flag is blocked when isHosted is true. - */ -export const isAuthDisabled = isTruthy(env.DISABLE_AUTH) && !isHosted - -if (isTruthy(env.DISABLE_AUTH)) { - import('@sim/logger') - .then(({ createLogger }) => { - const logger = createLogger('FeatureFlags') - if (isHosted) { - logger.error( - 'DISABLE_AUTH is set but ignored on hosted environment. Authentication remains enabled for security.' - ) - } else { - logger.warn( - 'DISABLE_AUTH is enabled. Authentication is bypassed and all requests use an anonymous session. Only use this in trusted private networks.' - ) - } - }) - .catch(() => { - // Fallback during config compilation when logger is unavailable - }) +export interface FeatureFlagsConfig { + flags: Record } /** - * Is user registration disabled - */ -export const isRegistrationDisabled = isTruthy(env.DISABLE_REGISTRATION) - -/** - * Is email/password authentication enabled (defaults to true) - */ -export const isEmailPasswordEnabled = !isFalsy(env.EMAIL_PASSWORD_SIGNUP_ENABLED) - -/** - * Is signup email validation enabled (disposable email blocking via better-auth-harmony) - */ -export const isSignupEmailValidationEnabled = isTruthy(env.SIGNUP_EMAIL_VALIDATION_ENABLED) - -/** - * Is MX-based signup validation enabled (blocks no-MX domains and denylisted shared spam - * mail backends). Opt-in to avoid adding a DNS dependency or blocking legitimate signups on - * self-hosted deployments with non-standard mail setups; enable on abuse-targeted deployments. - */ -export const isSignupMxValidationEnabled = isTruthy(env.SIGNUP_MX_VALIDATION_ENABLED) - -/** - * Is AWS AppConfig the source of truth for the signup/login gating lists. - * Hosted-only and requires both AppConfig identifiers (injected by the infra - * stack). Self-hosted/OSS deployments always use the env-var fallback, so the - * AppConfig client is never reached off-hosted. - */ -export const isAppConfigEnabled = - isHosted && Boolean(env.APPCONFIG_APPLICATION && env.APPCONFIG_ENVIRONMENT) - -/** - * Is Trigger.dev enabled for async job processing - */ -export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED) - -/** - * Is SSO enabled for enterprise authentication - */ -export const isSsoEnabled = isTruthy(env.SSO_ENABLED) - -/** - * Is credential sets (email polling) enabled via env var override - * This bypasses plan requirements for self-hosted deployments - */ -export const isCredentialSetsEnabled = isTruthy(env.CREDENTIAL_SETS_ENABLED) - -/** - * Is access control (permission groups) enabled via env var override - * This bypasses plan requirements for self-hosted deployments - */ -export const isAccessControlEnabled = isTruthy(env.ACCESS_CONTROL_ENABLED) - -/** - * Is organizations enabled - * True if billing is enabled (orgs come with billing), OR explicitly enabled via env var, - * OR if access control is enabled (access control requires organizations) - */ -export const isOrganizationsEnabled = - isBillingEnabled || isTruthy(env.ORGANIZATIONS_ENABLED) || isAccessControlEnabled - -/** - * Is inbox (Sim Mailer) enabled via env var override - * This bypasses hosted requirements for self-hosted deployments - */ -export const isInboxEnabled = isTruthy(env.INBOX_ENABLED) - -/** - * Is whitelabeling enabled via env var override - * This bypasses hosted requirements for self-hosted deployments - */ -export const isWhitelabelingEnabled = isTruthy(env.WHITELABELING_ENABLED) - -/** - * Is audit logs enabled via env var override - * This bypasses hosted requirements for self-hosted deployments - */ -export const isAuditLogsEnabled = isTruthy(env.AUDIT_LOGS_ENABLED) - -/** - * Is data retention enabled via env var override - * This bypasses hosted requirements for self-hosted deployments - */ -export const isDataRetentionEnabled = isTruthy(env.DATA_RETENTION_ENABLED) - -/** - * Is data drains enabled via env var override - * This bypasses hosted requirements for self-hosted deployments - */ -export const isDataDrainsEnabled = isTruthy(env.DATA_DRAINS_ENABLED) - -/** - * Are workflow output columns enabled in user tables. - * Defaults to false; set NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED=true to show - * the "Workflow" column type in the new-column dropdown. - */ -export const isWorkflowColumnsEnabledClient = isTruthy( - getEnv('NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED') -) - -/** - * Enables beta Mothership plan/changelog artifact surfaces. - */ -export const isMothershipBetaFeaturesEnabled = isTruthy(env.MOTHERSHIP_BETA_FEATURES) - -/** - * Is E2B enabled for remote code execution - */ -export const isE2bEnabled = isTruthy(env.E2B_ENABLED) - -/** - * Whether the E2B document-generation sandbox is enabled. - * - * Requires E2B (with an API key) AND a dedicated doc-generation template id. - * When true, ALL four formats compile in the E2B doc sandbox: pptx/docx via Node - * (pptxgenjs/docx + react-icons/sharp icons), pdf/xlsx via Python - * (reportlab/openpyxl). When false, compilation stays on the JavaScript - * (isolated-vm) path, byte-identical to its prior behavior (and xlsx is - * unavailable). Drives both the Sim compile backend and the `docCompiler` flag - * sent to the copilot file subagent so the agent's output and compiler agree. - */ -export const isE2BDocEnabled = - isE2bEnabled && Boolean(env.E2B_API_KEY) && Boolean(env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID) - -/** - * Whether Ollama is configured (OLLAMA_URL is set). - * When true, models that are not in the static cloud model list and have no - * slash-prefixed provider namespace are assumed to be Ollama models - * and do not require an API key. - */ -export const isOllamaConfigured = Boolean(env.OLLAMA_URL) - -/** - * Whether Azure OpenAI / Azure Anthropic credentials are pre-configured at the server level - * (via AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, AZURE_ANTHROPIC_ENDPOINT, etc.). - * When true, the endpoint, API key, and API version fields are hidden in the Agent block UI. - * Set NEXT_PUBLIC_AZURE_CONFIGURED=true in self-hosted deployments on Azure. - */ -export const isAzureConfigured = isTruthy(getEnv('NEXT_PUBLIC_AZURE_CONFIGURED')) - -/** - * Whether a Cohere API key is pre-configured server-side for the Knowledge block reranker - * (`COHERE_API_KEY` or `COHERE_API_KEY_1/2/3`). When true, the Cohere API Key field is hidden - * in the Knowledge block UI. - * Set NEXT_PUBLIC_COHERE_CONFIGURED=true in self-hosted deployments that ship a Cohere key. - */ -export const isCohereConfigured = isTruthy(getEnv('NEXT_PUBLIC_COHERE_CONFIGURED')) - -/** - * Are invitations disabled globally - * When true, workspace invitations are disabled for all users - */ -export const isInvitationsDisabled = isTruthy(env.DISABLE_INVITATIONS) - -/** - * Is public API access disabled globally - * When true, the public API toggle is hidden and public API access is blocked - */ -export const isPublicApiDisabled = isTruthy(env.DISABLE_PUBLIC_API) - -/** - * Is Google OAuth login disabled - * When true, the Google OAuth login button is hidden even when credentials are configured + * Per-request evaluation context. Pass only the ids you have — a missing id skips + * its clause. Admin status is resolved internally from `userId`; `isAdmin` is an + * optional fast-path override for callers that already know it (e.g. admin routes). */ -export const isGoogleAuthDisabled = isTruthy(env.DISABLE_GOOGLE_AUTH) +export interface FeatureFlagContext { + userId?: string | null + orgId?: string | null + isAdmin?: boolean +} /** - * Is GitHub OAuth login disabled - * When true, the GitHub OAuth login button is hidden even when credentials are configured + * Fallback flags used when AppConfig is not the source of truth (self-hosted/OSS, + * local dev, or hosted without APPCONFIG_*). When AppConfig is enabled it fully + * replaces this. Add/edit defaults here. */ -export const isGithubAuthDisabled = isTruthy(env.DISABLE_GITHUB_AUTH) +const DEFAULT_FEATURE_FLAGS: FeatureFlagsConfig = { + flags: { + // e.g. 'new-canvas': { admins: true }, + // e.g. 'beta-export': { orgIds: ['org_123'], userIds: ['user_abc'] }, + }, +} -/** - * Is React Grab enabled for UI element debugging - * When true and in development mode, enables React Grab for copying UI element context to clipboard - */ -export const isReactGrabEnabled = isDev && isTruthy(env.REACT_GRAB_ENABLED) +function normalizeIds(values: unknown): string[] | undefined { + if (!Array.isArray(values)) return undefined + const ids = Array.from(new Set(values.map((v) => String(v).trim()).filter(Boolean))) + return ids.length > 0 ? ids : undefined +} -/** - * Is React Scan enabled for performance debugging - * When true and in development mode, enables React Scan for detecting render performance issues - */ -export const isReactScanEnabled = isDev && isTruthy(env.REACT_SCAN_ENABLED) +function normalizeRule(value: unknown): FeatureFlagRule | null { + if (!value || typeof value !== 'object') return null + const obj = value as Record + const rule: FeatureFlagRule = {} + if (typeof obj.enabled === 'boolean') rule.enabled = obj.enabled + if (typeof obj.admins === 'boolean') rule.admins = obj.admins + const orgIds = normalizeIds(obj.orgIds) + if (orgIds) rule.orgIds = orgIds + const userIds = normalizeIds(obj.userIds) + if (userIds) rule.userIds = userIds + return rule +} -/** - * Returns the parsed allowlist of integration block types from the environment variable. - * If not set or empty, returns null (meaning all integrations are allowed). - */ -export function getAllowedIntegrationsFromEnv(): string[] | null { - if (!env.ALLOWED_INTEGRATIONS) return null - const parsed = env.ALLOWED_INTEGRATIONS.split(',') - .map((i) => i.trim().toLowerCase()) - .filter(Boolean) - return parsed.length > 0 ? parsed : null +/** Coerce an arbitrary AppConfig/JSON value into a config, dropping malformed entries. */ +function parseConfig(json: unknown): FeatureFlagsConfig { + const obj = (json && typeof json === 'object' ? json : {}) as Record + const rawFlags = (obj.flags && typeof obj.flags === 'object' ? obj.flags : {}) as Record< + string, + unknown + > + const flags: Record = {} + for (const [name, value] of Object.entries(rawFlags)) { + const rule = normalizeRule(value) + if (rule) flags[name] = rule + } + return { flags } } /** - * Returns the list of blacklisted provider IDs from the environment variable. - * If not set or empty, returns an empty array (meaning no providers are blacklisted). + * Resolve platform-admin status lazily. Dynamically imported so the DB-backed + * helper (and `@sim/db`) stay out of this config module's load graph for callers + * that never reach an admin-gated flag. */ -export function getBlacklistedProvidersFromEnv(): string[] { - if (!env.BLACKLISTED_PROVIDERS) return [] - return env.BLACKLISTED_PROVIDERS.split(',') - .map((p) => p.trim().toLowerCase()) - .filter(Boolean) +async function resolveAdmin(userId: string): Promise { + const { isPlatformAdmin } = await import('@/lib/permissions/super-user') + return isPlatformAdmin(userId) } /** - * Normalizes a domain entry from the ALLOWED_MCP_DOMAINS env var. - * Accepts bare hostnames (e.g., "mcp.company.com") or full URLs (e.g., "https://mcp.company.com"). - * Extracts the hostname in either case. - */ -function normalizeDomainEntry(entry: string): string { - const trimmed = entry.trim().toLowerCase() - if (!trimmed) return '' - if (trimmed.includes('://')) { - try { - return new URL(trimmed).hostname - } catch { - return trimmed - } + * The admin clause is resolved last and lazily: a global/userId/orgId match + * short-circuits before any DB read, a rule without `admins` never queries, and a + * missing `userId` resolves to `false` without a query. + */ +async function evaluate( + rule: FeatureFlagRule | undefined, + ctx: FeatureFlagContext +): Promise { + if (!rule) return false + if (rule.enabled) return true + if (ctx.userId && rule.userIds?.includes(ctx.userId)) return true + if (ctx.orgId && rule.orgIds?.includes(ctx.orgId)) return true + if (rule.admins) { + const admin = ctx.isAdmin ?? (ctx.userId ? await resolveAdmin(ctx.userId) : false) + if (admin) return true } - return trimmed + return false } /** - * Get allowed MCP server domains from the ALLOWED_MCP_DOMAINS env var. - * Returns null if not set (all domains allowed), or parsed array of lowercase hostnames. - * Accepts both bare hostnames and full URLs in the env var value. + * Resolve the full flag document. Reads from AWS AppConfig on hosted deployments + * (cached, ~30s TTL, never blocks after the first fetch), otherwise returns the + * in-file {@link DEFAULT_FEATURE_FLAGS}. */ -export function getAllowedMcpDomainsFromEnv(): string[] | null { - if (!env.ALLOWED_MCP_DOMAINS) return null - const parsed = env.ALLOWED_MCP_DOMAINS.split(',').map(normalizeDomainEntry).filter(Boolean) - return parsed.length > 0 ? parsed : null +export async function getFeatureFlags(): Promise { + if (!isAppConfigEnabled) return DEFAULT_FEATURE_FLAGS + + const value = await fetchAppConfigProfile( + { + application: env.APPCONFIG_APPLICATION as string, + environment: env.APPCONFIG_ENVIRONMENT as string, + profile: FEATURE_FLAGS_PROFILE, + }, + parseConfig + ) + + return value ?? DEFAULT_FEATURE_FLAGS } -/** - * Get cost multiplier based on environment - */ -export function getCostMultiplier(): number { - return isProd ? (env.COST_MULTIPLIER ?? 1) : 1 +/** Resolve a single flag for a context. Admin status is resolved internally from `userId`. */ +export async function isFeatureEnabled( + flag: string, + ctx: FeatureFlagContext = {} +): Promise { + const { flags } = await getFeatureFlags() + return evaluate(flags[flag], ctx) } diff --git a/apps/sim/lib/core/execution-limits/types.ts b/apps/sim/lib/core/execution-limits/types.ts index 774e87ac1ea..ae1174aadb3 100644 --- a/apps/sim/lib/core/execution-limits/types.ts +++ b/apps/sim/lib/core/execution-limits/types.ts @@ -1,6 +1,6 @@ import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers' import { env } from '@/lib/core/config/env' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types' interface ExecutionTimeoutConfig { diff --git a/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts b/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts index 23f0f90b892..a53a29b77ae 100644 --- a/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts +++ b/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts @@ -3,7 +3,7 @@ import { RateLimiter } from './rate-limiter' import type { ConsumeResult, RateLimitStorageAdapter, TokenStatus } from './storage' import { MANUAL_EXECUTION_LIMIT, RATE_LIMITS, RateLimitError } from './types' -vi.mock('@/lib/core/config/feature-flags', () => ({ isBillingEnabled: true })) +vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true })) interface MockAdapter { consumeTokens: Mock diff --git a/apps/sim/lib/core/rate-limiter/types.ts b/apps/sim/lib/core/rate-limiter/types.ts index 268b5e87805..dbf023af410 100644 --- a/apps/sim/lib/core/rate-limiter/types.ts +++ b/apps/sim/lib/core/rate-limiter/types.ts @@ -1,6 +1,6 @@ import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers' import { env } from '@/lib/core/config/env' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import type { CoreTriggerType } from '@/stores/logs/filters/types' import type { TokenBucketConfig } from './storage' diff --git a/apps/sim/lib/core/security/csp.test.ts b/apps/sim/lib/core/security/csp.test.ts index 9f315502262..a91c97d6501 100644 --- a/apps/sim/lib/core/security/csp.test.ts +++ b/apps/sim/lib/core/security/csp.test.ts @@ -1,4 +1,4 @@ -import { createEnvMock, featureFlagsMock } from '@sim/testing' +import { createEnvMock, envFlagsMock } from '@sim/testing' import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/config/env', () => @@ -17,7 +17,7 @@ vi.mock('@/lib/core/config/env', () => }) ) -vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) import { addCSPSource, diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts index 2dc60412c62..01ba261f8f7 100644 --- a/apps/sim/lib/core/security/csp.ts +++ b/apps/sim/lib/core/security/csp.ts @@ -1,5 +1,5 @@ import { env, getEnv } from '../config/env' -import { isDev, isHosted, isReactGrabEnabled } from '../config/feature-flags' +import { isDev, isHosted, isReactGrabEnabled } from '../config/env-flags' /** * Content Security Policy (CSP) configuration builder diff --git a/apps/sim/lib/core/security/deployment.ts b/apps/sim/lib/core/security/deployment.ts index aabab928be7..7bc03b2537b 100644 --- a/apps/sim/lib/core/security/deployment.ts +++ b/apps/sim/lib/core/security/deployment.ts @@ -3,7 +3,7 @@ import { sha256Hex } from '@sim/security/hash' import { hmacSha256Hex } from '@sim/security/hmac' import type { NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' -import { isDev } from '@/lib/core/config/feature-flags' +import { isDev } from '@/lib/core/config/env-flags' /** * Shared authentication utilities for deployed chat endpoints. diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index cdf5f28a9a1..1a847606704 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -5,7 +5,7 @@ import type { LookupFunction } from 'net' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import * as ipaddr from 'ipaddr.js' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import { type ValidationResult, validateExternalUrl } from '@/lib/core/security/input-validation' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index 55b07a72db0..7e8be4caa12 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -1,4 +1,4 @@ -import { featureFlagsMock } from '@sim/testing' +import { envFlagsMock } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { validateAirtableId, @@ -32,7 +32,7 @@ import { } from '@/lib/core/security/input-validation.server' import { sanitizeForLogging } from '@/lib/core/security/redaction' -vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) describe('validatePathSegment', () => { describe('valid inputs', () => { diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index 98ac9e1c982..28b2fc1d5f1 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import * as ipaddr from 'ipaddr.js' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' const logger = createLogger('InputValidation') diff --git a/apps/sim/lib/core/utils/urls.test.ts b/apps/sim/lib/core/utils/urls.test.ts index 689f5b51a9b..2878a49feb1 100644 --- a/apps/sim/lib/core/utils/urls.test.ts +++ b/apps/sim/lib/core/utils/urls.test.ts @@ -12,7 +12,7 @@ vi.mock('@/lib/core/config/env', () => ({ getEnv: mockGetEnv, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ isProd: false, })) diff --git a/apps/sim/lib/core/utils/urls.ts b/apps/sim/lib/core/utils/urls.ts index 1c2da16ec5b..1a014b295d6 100644 --- a/apps/sim/lib/core/utils/urls.ts +++ b/apps/sim/lib/core/utils/urls.ts @@ -1,5 +1,5 @@ import { env, getEnv } from '@/lib/core/config/env' -import { isProd } from '@/lib/core/config/feature-flags' +import { isProd } from '@/lib/core/config/env-flags' /** Canonical base URL for the public-facing marketing site. No trailing slash. */ export const SITE_URL = 'https://www.sim.ai' diff --git a/apps/sim/lib/data-drains/access.ts b/apps/sim/lib/data-drains/access.ts index 6a36170f6f5..5a1db375444 100644 --- a/apps/sim/lib/data-drains/access.ts +++ b/apps/sim/lib/data-drains/access.ts @@ -4,7 +4,7 @@ import { and, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' -import { isBillingEnabled, isDataDrainsEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled, isDataDrainsEnabled } from '@/lib/core/config/env-flags' interface DrainAccessSession { user: { diff --git a/apps/sim/lib/data-drains/dispatcher.test.ts b/apps/sim/lib/data-drains/dispatcher.test.ts index ffffac51472..0d94887b7dd 100644 --- a/apps/sim/lib/data-drains/dispatcher.test.ts +++ b/apps/sim/lib/data-drains/dispatcher.test.ts @@ -19,7 +19,7 @@ vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationOnEnterprisePlan: mockIsEnterprise, })) vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: mockGetJobQueue })) -vi.mock('@/lib/core/config/feature-flags', () => ({ isBillingEnabled: true })) +vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true })) import { dispatchDueDrains, reapOrphanedRuns } from '@/lib/data-drains/dispatcher' diff --git a/apps/sim/lib/data-drains/dispatcher.ts b/apps/sim/lib/data-drains/dispatcher.ts index c7021ed9a6c..aec56a3bf3a 100644 --- a/apps/sim/lib/data-drains/dispatcher.ts +++ b/apps/sim/lib/data-drains/dispatcher.ts @@ -5,7 +5,7 @@ import { toError } from '@sim/utils/errors' import { and, eq, isNull, lt, or } from 'drizzle-orm' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { getJobQueue } from '@/lib/core/async-jobs' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' const logger = createLogger('DataDrainsDispatcher') diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index 6c81664db2e..5bd5040e121 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -52,7 +52,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: mockGetWorkspaceWithOwner, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isBillingEnabled() { return mockFeatureFlags.isBillingEnabled }, diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index 979eb11cf53..b0808ccb180 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -26,7 +26,7 @@ import { } from '@/lib/billing/organizations/membership' import { ensureTeamOrganizationForAcceptance } from '@/lib/billing/organizations/provision-seat' import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' import { applyWorkspaceAutoAddGroup } from '@/lib/permission-groups/auto-add' import { captureServerEvent } from '@/lib/posthog/server' diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 6e906c3bdd4..7676355a8cc 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -33,7 +33,7 @@ import { recordUsage } from '@/lib/billing/core/usage-log' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' import { env, envNumber } from '@/lib/core/config/env' -import { getCostMultiplier, isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { getCostMultiplier, isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { processDocument } from '@/lib/knowledge/documents/document-processor' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' import { getEmbeddingModelInfo } from '@/lib/knowledge/embedding-models' diff --git a/apps/sim/lib/knowledge/reranker.ts b/apps/sim/lib/knowledge/reranker.ts index e9b0fea8500..4c20847f535 100644 --- a/apps/sim/lib/knowledge/reranker.ts +++ b/apps/sim/lib/knowledge/reranker.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { getBYOKKey } from '@/lib/api-key/byok' import { getRotatingApiKey } from '@/lib/core/config/api-keys' import { env } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import { isRetryableError, retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils' import { isSupportedRerankerModel } from '@/lib/knowledge/reranker-models' diff --git a/apps/sim/lib/logs/execution/logger.test.ts b/apps/sim/lib/logs/execution/logger.test.ts index 3acb14dcae6..923542fde12 100644 --- a/apps/sim/lib/logs/execution/logger.test.ts +++ b/apps/sim/lib/logs/execution/logger.test.ts @@ -1,4 +1,4 @@ -import { featureFlagsMock } from '@sim/testing' +import { envFlagsMock } from '@sim/testing' import { beforeEach, describe, expect, test, vi } from 'vitest' import { recordUsage } from '@/lib/billing/core/usage-log' import { ExecutionLogger } from '@/lib/logs/execution/logger' @@ -60,7 +60,7 @@ vi.mock('@/lib/billing/threshold-billing', () => ({ checkAndBillOverageThreshold: vi.fn(() => Promise.resolve()), })) -vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) // Mock security module vi.mock('@/lib/core/security/redaction', () => ({ diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 9733a5550d6..e8c6edd7c55 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -25,7 +25,7 @@ import { stableEventKey, } from '@/lib/billing/core/usage-log' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { redactApiKeys } from '@/lib/core/security/redaction' import { filterForDisplay } from '@/lib/core/utils/display-filters' import { diff --git a/apps/sim/lib/mcp/connection-manager.test.ts b/apps/sim/lib/mcp/connection-manager.test.ts index 8b48392e52f..f4845b099ec 100644 --- a/apps/sim/lib/mcp/connection-manager.test.ts +++ b/apps/sim/lib/mcp/connection-manager.test.ts @@ -40,7 +40,7 @@ const { mockGetOrCreateOauthRow: vi.fn(), })) -vi.mock('@/lib/core/config/feature-flags', () => ({ isTest: false })) +vi.mock('@/lib/core/config/env-flags', () => ({ isTest: false })) vi.mock('@/lib/mcp/pubsub', () => ({ mcpPubSub: { onToolsChanged: mockOnToolsChanged, diff --git a/apps/sim/lib/mcp/connection-manager.ts b/apps/sim/lib/mcp/connection-manager.ts index 158983739b2..78b8acc265f 100644 --- a/apps/sim/lib/mcp/connection-manager.ts +++ b/apps/sim/lib/mcp/connection-manager.ts @@ -12,7 +12,7 @@ import { createLogger } from '@sim/logger' import { backoffWithJitter } from '@sim/utils/retry' -import { isTest } from '@/lib/core/config/feature-flags' +import { isTest } from '@/lib/core/config/env-flags' import { McpClient } from '@/lib/mcp/client' import { getOrCreateOauthRow, loadPreregisteredClient, SimMcpOauthProvider } from '@/lib/mcp/oauth' import { mcpPubSub } from '@/lib/mcp/pubsub' diff --git a/apps/sim/lib/mcp/domain-check.test.ts b/apps/sim/lib/mcp/domain-check.test.ts index ff559caa8cf..22497bfe540 100644 --- a/apps/sim/lib/mcp/domain-check.test.ts +++ b/apps/sim/lib/mcp/domain-check.test.ts @@ -10,7 +10,7 @@ const { mockGetAllowedMcpDomainsFromEnv, mockDnsLookup, hostedFlag } = vi.hoiste hostedFlag: { value: false }, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ getAllowedMcpDomainsFromEnv: mockGetAllowedMcpDomainsFromEnv, get isHosted() { return hostedFlag.value diff --git a/apps/sim/lib/mcp/domain-check.ts b/apps/sim/lib/mcp/domain-check.ts index 9e57b23c7f4..fcc721203c2 100644 --- a/apps/sim/lib/mcp/domain-check.ts +++ b/apps/sim/lib/mcp/domain-check.ts @@ -2,7 +2,7 @@ import dns from 'dns/promises' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import * as ipaddr from 'ipaddr.js' -import { getAllowedMcpDomainsFromEnv, isHosted } from '@/lib/core/config/feature-flags' +import { getAllowedMcpDomainsFromEnv, isHosted } from '@/lib/core/config/env-flags' import { isPrivateOrReservedIP } from '@/lib/core/security/input-validation.server' import { createEnvVarPattern } from '@/executor/utils/reference-validation' diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index 4ef53382483..12c30e7c8c3 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -6,7 +6,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { and, eq, isNull } from 'drizzle-orm' -import { isTest } from '@/lib/core/config/feature-flags' +import { isTest } from '@/lib/core/config/env-flags' import { generateRequestId } from '@/lib/core/utils/request' import { McpClient } from '@/lib/mcp/client' import { mcpConnectionManager } from '@/lib/mcp/connection-manager' diff --git a/apps/sim/lib/messaging/lifecycle.ts b/apps/sim/lib/messaging/lifecycle.ts index 5d6da198810..95b109d9cb6 100644 --- a/apps/sim/lib/messaging/lifecycle.ts +++ b/apps/sim/lib/messaging/lifecycle.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { tasks } from '@trigger.dev/sdk' import { env } from '@/lib/core/config/env' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' const logger = createLogger('LifecycleEmail') diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 1e259072117..e82025d853f 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -16,7 +16,7 @@ import { chatPubSub } from '@/lib/copilot/chat-status' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestChatTitle } from '@/lib/copilot/request/lifecycle/start' import type { OrchestratorResult } from '@/lib/copilot/request/types' -import { isE2BDocEnabled, isHosted } from '@/lib/core/config/feature-flags' +import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags' import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { formatEmailAsMessage } from '@/lib/mothership/inbox/format' import { sendInboxResponse } from '@/lib/mothership/inbox/response' diff --git a/apps/sim/lib/permissions/super-user.ts b/apps/sim/lib/permissions/super-user.ts index 953a2ea1578..597ca135e4c 100644 --- a/apps/sim/lib/permissions/super-user.ts +++ b/apps/sim/lib/permissions/super-user.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import { db, dbReplica } from '@sim/db' import { settings, user } from '@sim/db/schema' import { eq } from 'drizzle-orm' @@ -35,3 +35,19 @@ export async function verifyEffectiveSuperUser(userId: string): Promise<{ superUserModeEnabled, } } + +/** + * True when the user is a platform admin (`role === 'admin'`). A single-column read + * served from the replica: this gates features, not security-critical auth, so it + * tolerates the replica's bounded staleness (admin role rarely changes). Falls back + * to the primary when no replica is configured. + */ +export async function isPlatformAdmin(userId: string): Promise { + const [row] = await dbReplica + .select({ role: user.role }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + + return row?.role === 'admin' +} diff --git a/apps/sim/lib/table/__tests__/update-row.test.ts b/apps/sim/lib/table/__tests__/update-row.test.ts index 38a74695c3d..7ddd2811e1c 100644 --- a/apps/sim/lib/table/__tests__/update-row.test.ts +++ b/apps/sim/lib/table/__tests__/update-row.test.ts @@ -19,7 +19,7 @@ vi.mock('@sim/db', () => dbChainMock) // These suites assert flag-off position-shift semantics; pin the flag so they're // deterministic regardless of a local TABLES_FRACTIONAL_ORDERING env value. -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ isTablesFractionalOrderingEnabled: false, })) diff --git a/apps/sim/lib/table/backfill-runner.ts b/apps/sim/lib/table/backfill-runner.ts index cbaf6e640f2..716e28e1884 100644 --- a/apps/sim/lib/table/backfill-runner.ts +++ b/apps/sim/lib/table/backfill-runner.ts @@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, asc, count, eq, gt, inArray } from 'drizzle-orm' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { runDetached } from '@/lib/core/utils/background' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 2c526fa9b2d..3ed6d15a83f 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -30,7 +30,7 @@ import { type SQL, sql, } from 'drizzle-orm' -import { isTablesFractionalOrderingEnabled } from '@/lib/core/config/feature-flags' +import { isTablesFractionalOrderingEnabled } from '@/lib/core/config/env-flags' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' import { diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index 8adbe32a5eb..edc0b6c6362 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -16,7 +16,7 @@ import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray, notInArray, sql } from 'drizzle-orm' import type { EnqueueOptions } from '@/lib/core/async-jobs/types' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { buildCancelledExecution } from '@/lib/table/cell-write' import type { Filter, diff --git a/apps/sim/lib/webhooks/processor.test.ts b/apps/sim/lib/webhooks/processor.test.ts index 381cc6108a0..60f26d86c33 100644 --- a/apps/sim/lib/webhooks/processor.test.ts +++ b/apps/sim/lib/webhooks/processor.test.ts @@ -4,9 +4,9 @@ import { createMockRequest, + envFlagsMock, executionPreprocessingMock, executionPreprocessingMockFns, - featureFlagsMock, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -67,7 +67,7 @@ vi.mock('@/lib/core/async-jobs', () => ({ shouldExecuteInline: mockShouldExecuteInline, })) -vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) vi.mock('@sim/security/compare', () => ({ safeCompare: vi.fn().mockReturnValue(true), diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 3352048b94f..ca4a38ef7e5 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -9,7 +9,7 @@ import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing/core/subscri import { tryAdmit } from '@/lib/core/admission/gate' import { getInlineJobQueue, getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' -import { isProd } from '@/lib/core/config/feature-flags' +import { isProd } from '@/lib/core/config/env-flags' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { preprocessExecution } from '@/lib/execution/preprocessing' import { diff --git a/apps/sim/lib/workflows/subblocks/visibility.ts b/apps/sim/lib/workflows/subblocks/visibility.ts index fb8de2121a8..b49d2730df5 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.ts @@ -1,5 +1,5 @@ import { getEnv, isTruthy } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import type { SubBlockConfig } from '@/blocks/types' export type CanonicalMode = 'basic' | 'advanced' diff --git a/apps/sim/lib/workspaces/policy.test.ts b/apps/sim/lib/workspaces/policy.test.ts index 9c75eda7218..0828cff4288 100644 --- a/apps/sim/lib/workspaces/policy.test.ts +++ b/apps/sim/lib/workspaces/policy.test.ts @@ -58,7 +58,7 @@ vi.mock('@/lib/billing/core/plan', () => ({ getHighestPrioritySubscription: mockGetHighestPrioritySubscription, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isBillingEnabled() { return mockFeatureFlags.isBillingEnabled }, diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index 26cd530ea9e..641dd15e9af 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -8,7 +8,7 @@ import { getUserOrganization } from '@/lib/billing/organizations/membership' import type { PlanCategory } from '@/lib/billing/plan-helpers' import { getPlanType, isEnterprise, isMax, isPro, isTeam } from '@/lib/billing/plan-helpers' import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants' const logger = createLogger('WorkspacePolicy') diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 4bbcb456b1a..33fd8e5f563 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -1,6 +1,6 @@ import type { NextConfig } from 'next' import { env, isTruthy } from './lib/core/config/env' -import { isDev } from './lib/core/config/feature-flags' +import { isDev } from './lib/core/config/env-flags' import { getChatEmbedCSPPolicy, getMainCSPPolicy, diff --git a/apps/sim/providers/index.test.ts b/apps/sim/providers/index.test.ts index 19d0a41ac84..73b62a79abc 100644 --- a/apps/sim/providers/index.test.ts +++ b/apps/sim/providers/index.test.ts @@ -18,7 +18,7 @@ vi.mock('@/providers/registry', () => ({ }), })) -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ getCostMultiplier: vi.fn(() => 1), })) diff --git a/apps/sim/providers/index.ts b/apps/sim/providers/index.ts index 19dcb5fda6b..26433940e33 100644 --- a/apps/sim/providers/index.ts +++ b/apps/sim/providers/index.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { getApiKeyWithBYOK } from '@/lib/api-key/byok' -import { getCostMultiplier } from '@/lib/core/config/feature-flags' +import { getCostMultiplier } from '@/lib/core/config/env-flags' import type { StreamingExecution } from '@/executor/types' import { getProviderExecutor } from '@/providers/registry' import type { ProviderId, ProviderRequest, ProviderResponse } from '@/providers/types' diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 46d414cbb66..9ed017b581b 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import * as environmentModule from '@/lib/core/config/feature-flags' +import * as environmentModule from '@/lib/core/config/env-flags' import { calculateCost, extractAndParseJSON, diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index c584261b4ec..2c22c865e4a 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -5,7 +5,7 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' import { formatCreditCost } from '@/lib/billing/credits/conversion' import { env } from '@/lib/core/config/env' -import { getBlacklistedProvidersFromEnv, isHosted } from '@/lib/core/config/feature-flags' +import { getBlacklistedProvidersFromEnv, isHosted } from '@/lib/core/config/env-flags' import { normalizeRecord, normalizeStringRecord, diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 0bf9114699d..1d5e0581589 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -3,7 +3,7 @@ import { getSessionCookie } from 'better-auth/cookies' import { type NextRequest, NextResponse } from 'next/server' import { sendToProfound } from './lib/analytics/profound' import { getEnv } from './lib/core/config/env' -import { isAuthDisabled, isHosted } from './lib/core/config/feature-flags' +import { isAuthDisabled, isHosted } from './lib/core/config/env-flags' import { generateRuntimeCSP } from './lib/core/security/csp' import { getClientIp } from './lib/core/utils/request' diff --git a/apps/sim/scripts/process-docs.ts b/apps/sim/scripts/process-docs.ts index bef7938ccbb..1d8c5cf3554 100644 --- a/apps/sim/scripts/process-docs.ts +++ b/apps/sim/scripts/process-docs.ts @@ -6,7 +6,7 @@ import { docsEmbeddings } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { sql } from 'drizzle-orm' import { type DocChunk, DocsChunker } from '@/lib/chunkers' -import { isDev } from '@/lib/core/config/feature-flags' +import { isDev } from '@/lib/core/config/env-flags' const logger = createLogger('ProcessDocs') diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index fc22a66fab8..0b4d4ccbd58 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -51,7 +51,7 @@ const mockSecureFetchWithPinnedIP = inputValidationMockFns.mockSecureFetchWithPi const mockValidateUrlWithDNS = inputValidationMockFns.mockValidateUrlWithDNS // Mock feature flags -vi.mock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/env-flags', () => ({ get isHosted() { return mockIsHosted.value }, diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index ca122a6c054..28eb17aaebe 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -4,7 +4,7 @@ import { sleep } from '@sim/utils/helpers' import { randomFloat } from '@sim/utils/random' import { getBYOKKey } from '@/lib/api-key/byok' import { generateInternalToken } from '@/lib/auth/internal' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted } from '@/lib/core/config/env-flags' import { DEFAULT_EXECUTION_TIMEOUT_MS, getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { getHostedKeyRateLimiter } from '@/lib/core/rate-limiter' import { diff --git a/apps/sim/tools/supabase/utils.test.ts b/apps/sim/tools/supabase/utils.test.ts index ac649b008f9..b12e98ac421 100644 --- a/apps/sim/tools/supabase/utils.test.ts +++ b/apps/sim/tools/supabase/utils.test.ts @@ -1,10 +1,10 @@ /** * @vitest-environment node */ -import { featureFlagsMock } from '@sim/testing' +import { envFlagsMock } from '@sim/testing' import { describe, expect, it, vi } from 'vitest' -vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) import { supabaseBaseUrl } from '@/tools/supabase/utils' diff --git a/packages/testing/src/mocks/feature-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts similarity index 88% rename from packages/testing/src/mocks/feature-flags.mock.ts rename to packages/testing/src/mocks/env-flags.mock.ts index da512c99a8d..02be7bfbbff 100644 --- a/packages/testing/src/mocks/feature-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -1,15 +1,15 @@ import { vi } from 'vitest' /** - * Static mock module for `@/lib/core/config/feature-flags`. + * Static mock module for `@/lib/core/config/env-flags`. * All boolean flags default to `false` for safe test isolation. * * @example * ```ts - * vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + * vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) * ``` */ -export const featureFlagsMock = { +export const envFlagsMock = { isProd: false, isDev: false, isTest: true, diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index e8e4de49e24..7bff8617850 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -52,13 +52,13 @@ export { export { encryptionMock, encryptionMockFns } from './encryption.mock' // Env mocks export { createEnvMock, createMockGetEnv, defaultMockEnv, envMock } from './env.mock' +// Env flag mocks +export { envFlagsMock } from './env-flags.mock' // Execution preprocessing mocks (for @/lib/execution/preprocessing) export { executionPreprocessingMock, executionPreprocessingMockFns, } from './execution-preprocessing.mock' -// Feature flag mocks -export { featureFlagsMock } from './feature-flags.mock' // Executor mocks - use side-effect import: import '@sim/testing/mocks/executor' // Fetch mocks export { From d0e0660a0f2e019fc9c721e4623412a6a7903a1d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 16:38:09 -0700 Subject: [PATCH 2/6] fix(ci): repoint 'Validate feature flags' step to env-flags.ts after rename --- .github/workflows/test-build.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index e59102ebd58..53613e07751 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -55,12 +55,12 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Validate feature flags + - name: Validate env flags run: | - FILE="apps/sim/lib/core/config/feature-flags.ts" + FILE="apps/sim/lib/core/config/env-flags.ts" ERRORS="" - echo "Checking for hardcoded boolean feature flags..." + echo "Checking for hardcoded boolean env flags..." # Use perl for multiline matching to catch both: # export const isHosted = true @@ -69,17 +69,17 @@ jobs: HARDCODED=$(perl -0777 -ne 'while (/export const (is[A-Za-z]+)\s*=\s*\n?\s*(true|false)\b/g) { print " $1 = $2\n" }' "$FILE") if [ -n "$HARDCODED" ]; then - 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" + 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" fi - echo "Checking feature flag naming conventions..." + echo "Checking env flag naming conventions..." # Check that all export const (except functions) start with 'is' # This finds exports like "export const someFlag" that don't start with "is" or "get" BAD_NAMES=$(grep -E "^export const [a-z]" "$FILE" | grep -vE "^export const (is|get)" | sed 's/export const \([a-zA-Z]*\).*/ \1/') if [ -n "$BAD_NAMES" ]; then - 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" + 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" fi if [ -n "$ERRORS" ]; then @@ -88,7 +88,7 @@ jobs: exit 1 fi - echo "✅ All feature flags are properly configured" + echo "✅ All env flags are properly configured" - name: Check block registry invariants run: | From 1989bf860b6c6e2939158cfbf7d69fb71bb0f770 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 18:14:40 -0700 Subject: [PATCH 3/6] improvement(feature-flags): drop in-code defaults; fallback resolves a per-flag secret, gating is AppConfig-only --- .claude/commands/add-feature-flag.md | 27 +++++++------- .cursor/commands/add-feature-flag.md | 25 +++++++------ .../sim/lib/core/config/feature-flags.test.ts | 4 +- apps/sim/lib/core/config/feature-flags.ts | 37 ++++++++++++------- 4 files changed, 53 insertions(+), 40 deletions(-) diff --git a/.claude/commands/add-feature-flag.md b/.claude/commands/add-feature-flag.md index 2a55a38128d..b1fcb99340a 100644 --- a/.claude/commands/add-feature-flag.md +++ b/.claude/commands/add-feature-flag.md @@ -1,11 +1,11 @@ --- -description: Add a runtime gated feature flag (AppConfig-backed on prod, in-file default off-prod), gated by org id, user id, or admin +description: Add a runtime gated feature flag (AppConfig-backed on prod, secret fallback off-prod), gated by org id, user id, or admin argument-hint: --- # Add Feature Flag Skill -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. +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). ## When to use this vs `env-flags.ts` @@ -16,7 +16,7 @@ If the user wants a fixed per-deployment toggle, send them to `env-flags.ts` ins ## The flag model -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: +A flag's **gating rule lives only in the hosted AppConfig document**. It is ON for a context when any clause matches: ```ts interface FeatureFlagRule { @@ -27,19 +27,19 @@ interface FeatureFlagRule { } ``` +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. + ## Steps -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: +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: ```ts - const DEFAULT_FEATURE_FLAGS: FeatureFlagsConfig = { - flags: { - '': { admins: true }, - }, + const FEATURE_FLAG_FALLBACKS = { + '': () => env., } ``` - Default conservatively (usually `{ admins: true }` or empty `{}` so it's off for everyone until you roll out). + Add `` 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. 2. **Gate the call site.** Call `isFeatureEnabled` with whatever ids you have — admin status is resolved internally, so callers never pass it: @@ -55,14 +55,15 @@ interface FeatureFlagRule { - Admin routes that already know the caller is an admin may pass `{ userId, isAdmin: true }` to skip the role lookup. - **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. -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--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. +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--fast` deployment (see the AppConfig runbook in the infra README — same flow as `access-control`). The fallback secret only applies when AppConfig is disabled. -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). +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. -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. +5. **Clean up after rollout.** When the feature ships to everyone, delete the flag from `FEATURE_FLAG_FALLBACKS`, the `` env entry, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems. ## Notes -- Tool IDs / flag keys are `kebab-case`. +- Flag keys are `kebab-case`. - Never read flags via raw `fetch` or a new AppConfig client — always go through `isFeatureEnabled` / `getFeatureFlags`. +- Never bake gating into code. The fallback is a single boolean secret; org/user/admin scoping is AppConfig-only. - 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. diff --git a/.cursor/commands/add-feature-flag.md b/.cursor/commands/add-feature-flag.md index 34c010f5386..81618dd4bc3 100644 --- a/.cursor/commands/add-feature-flag.md +++ b/.cursor/commands/add-feature-flag.md @@ -1,6 +1,6 @@ # Add Feature Flag Skill -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. +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). ## When to use this vs `env-flags.ts` @@ -11,7 +11,7 @@ If the user wants a fixed per-deployment toggle, send them to `env-flags.ts` ins ## The flag model -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: +A flag's **gating rule lives only in the hosted AppConfig document**. It is ON for a context when any clause matches: ```ts interface FeatureFlagRule { @@ -22,19 +22,19 @@ interface FeatureFlagRule { } ``` +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. + ## Steps -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: +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: ```ts - const DEFAULT_FEATURE_FLAGS: FeatureFlagsConfig = { - flags: { - '': { admins: true }, - }, + const FEATURE_FLAG_FALLBACKS = { + '': () => env., } ``` - Default conservatively (usually `{ admins: true }` or empty `{}` so it's off for everyone until you roll out). + Add `` 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. 2. **Gate the call site.** Call `isFeatureEnabled` with whatever ids you have — admin status is resolved internally, so callers never pass it: @@ -50,14 +50,15 @@ interface FeatureFlagRule { - Admin routes that already know the caller is an admin may pass `{ userId, isAdmin: true }` to skip the role lookup. - **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. -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--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. +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--fast` deployment (see the AppConfig runbook in the infra README — same flow as `access-control`). The fallback secret only applies when AppConfig is disabled. -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). +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. -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. +5. **Clean up after rollout.** When the feature ships to everyone, delete the flag from `FEATURE_FLAG_FALLBACKS`, the `` env entry, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems. ## Notes -- Tool IDs / flag keys are `kebab-case`. +- Flag keys are `kebab-case`. - Never read flags via raw `fetch` or a new AppConfig client — always go through `isFeatureEnabled` / `getFeatureFlags`. +- Never bake gating into code. The fallback is a single boolean secret; org/user/admin scoping is AppConfig-only. - 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. diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index 2592d3c0e78..983705a4173 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -48,7 +48,7 @@ describe('getFeatureFlags', () => { flagRef.isAppConfigEnabled = false }) - it('returns the in-file default (empty) when AppConfig is disabled, without fetching', async () => { + it('derives flags from fallback secrets (empty registry → empty) when AppConfig is disabled, without fetching', async () => { expect(await getFeatureFlags()).toEqual({ flags: {} }) expect(mockFetch).not.toHaveBeenCalled() }) @@ -72,7 +72,7 @@ describe('getFeatureFlags', () => { ) }) - it('falls back to the in-file default when the fetch yields null', async () => { + it('falls back to the secret-derived document when the fetch yields null', async () => { flagRef.isAppConfigEnabled = true mockFetch.mockResolvedValue(null) expect(await getFeatureFlags()).toEqual({ flags: {} }) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index d83ffe252fd..1630c5610db 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -1,5 +1,5 @@ import { fetchAppConfigProfile } from '@/lib/core/config/appconfig' -import { env } from '@/lib/core/config/env' +import { env, isTruthy } from '@/lib/core/config/env' import { isAppConfigEnabled } from '@/lib/core/config/env-flags' /** @@ -37,15 +37,26 @@ export interface FeatureFlagContext { } /** - * Fallback flags used when AppConfig is not the source of truth (self-hosted/OSS, - * local dev, or hosted without APPCONFIG_*). When AppConfig is enabled it fully - * replaces this. Add/edit defaults here. + * Registry of known feature flags. Each maps to the secret consulted ONLY when + * AppConfig is not the source of truth (self-hosted/OSS, local dev, or hosted + * without APPCONFIG_*). A truthy secret turns the flag on globally. + * + * Gating by org/user/admin is available ONLY through the hosted AppConfig document + * — it deliberately cannot be expressed here, so no environment can grant (e.g.) + * admin access from a code literal. To add a flag, register its name and the secret + * to fall back on. */ -const DEFAULT_FEATURE_FLAGS: FeatureFlagsConfig = { - flags: { - // e.g. 'new-canvas': { admins: true }, - // e.g. 'beta-export': { orgIds: ['org_123'], userIds: ['user_abc'] }, - }, +const FEATURE_FLAG_FALLBACKS: Record string | boolean | number | undefined> = { + // 'new-canvas': () => env.NEW_CANVAS_ENABLED, +} + +/** Build the fallback document from each flag's secret. Truthy secret ⇒ enabled. */ +function fallbackFlags(): FeatureFlagsConfig { + const flags: Record = {} + for (const [name, readSecret] of Object.entries(FEATURE_FLAG_FALLBACKS)) { + flags[name] = { enabled: isTruthy(readSecret()) } + } + return { flags } } function normalizeIds(values: unknown): string[] | undefined { @@ -114,11 +125,11 @@ async function evaluate( /** * Resolve the full flag document. Reads from AWS AppConfig on hosted deployments - * (cached, ~30s TTL, never blocks after the first fetch), otherwise returns the - * in-file {@link DEFAULT_FEATURE_FLAGS}. + * (cached, ~30s TTL, never blocks after the first fetch), otherwise derives each + * flag's on/off state from its registered fallback secret ({@link fallbackFlags}). */ export async function getFeatureFlags(): Promise { - if (!isAppConfigEnabled) return DEFAULT_FEATURE_FLAGS + if (!isAppConfigEnabled) return fallbackFlags() const value = await fetchAppConfigProfile( { @@ -129,7 +140,7 @@ export async function getFeatureFlags(): Promise { parseConfig ) - return value ?? DEFAULT_FEATURE_FLAGS + return value ?? fallbackFlags() } /** Resolve a single flag for a context. Admin status is resolved internally from `userId`. */ From 0853240860d68fc7279777cd8857373c97043599 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 18:32:05 -0700 Subject: [PATCH 4/6] improvement(feature-flags): make flag names a closed set so every flag requires a fallback secret --- .../sim/lib/core/config/feature-flags.test.ts | 42 ++++++++++++------- apps/sim/lib/core/config/feature-flags.ts | 18 ++++++-- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index 983705a4173..d2250a60f81 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -2,7 +2,11 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { FeatureFlagsConfig } from '@/lib/core/config/feature-flags' +import type { + FeatureFlagContext, + FeatureFlagName, + FeatureFlagsConfig, +} from '@/lib/core/config/feature-flags' const { mockFetch, mockIsPlatformAdmin, envRef, flagRef } = vi.hoisted(() => ({ mockFetch: vi.fn(), @@ -42,6 +46,14 @@ function withAppConfig(doc: unknown) { mockFetch.mockImplementation((_ids, parse) => Promise.resolve(parse(doc))) } +/** + * `isFeatureEnabled` only accepts registered `FeatureFlagName`s. The registry is + * empty in this PR, so tests reference flags through the AppConfig document and + * cast their throwaway names through this helper. + */ +const enabled = (flag: string, ctx?: FeatureFlagContext) => + isFeatureEnabled(flag as FeatureFlagName, ctx) + describe('getFeatureFlags', () => { beforeEach(() => { vi.clearAllMocks() @@ -94,62 +106,62 @@ describe('isFeatureEnabled', () => { it('returns false for an unknown flag', async () => { withAppConfig({ flags: {} }) - expect(await isFeatureEnabled('missing', { userId: 'u1' })).toBe(false) + expect(await enabled('missing', { userId: 'u1' })).toBe(false) }) it('matches the global enabled clause', async () => { withAppConfig({ flags: { f: { enabled: true } } }) - expect(await isFeatureEnabled('f')).toBe(true) + expect(await enabled('f')).toBe(true) }) it('matches the userId allowlist', async () => { withAppConfig({ flags: { f: { userIds: ['u1'] } } }) - expect(await isFeatureEnabled('f', { userId: 'u1' })).toBe(true) - expect(await isFeatureEnabled('f', { userId: 'u2' })).toBe(false) - expect(await isFeatureEnabled('f', {})).toBe(false) + expect(await enabled('f', { userId: 'u1' })).toBe(true) + expect(await enabled('f', { userId: 'u2' })).toBe(false) + expect(await enabled('f', {})).toBe(false) }) it('matches the orgId allowlist', async () => { withAppConfig({ flags: { f: { orgIds: ['o1'] } } }) - expect(await isFeatureEnabled('f', { orgId: 'o1' })).toBe(true) - expect(await isFeatureEnabled('f', { orgId: 'o2' })).toBe(false) + expect(await enabled('f', { orgId: 'o1' })).toBe(true) + expect(await enabled('f', { orgId: 'o2' })).toBe(false) }) describe('admin clause (lazy resolution)', () => { it('resolves admin from userId when admins is the deciding clause', async () => { withAppConfig({ flags: { f: { admins: true } } }) mockIsPlatformAdmin.mockResolvedValue(true) - expect(await isFeatureEnabled('f', { userId: 'u1' })).toBe(true) + expect(await enabled('f', { userId: 'u1' })).toBe(true) expect(mockIsPlatformAdmin).toHaveBeenCalledWith('u1') mockIsPlatformAdmin.mockResolvedValue(false) - expect(await isFeatureEnabled('f', { userId: 'u2' })).toBe(false) + expect(await enabled('f', { userId: 'u2' })).toBe(false) }) it('uses the isAdmin override without querying', async () => { withAppConfig({ flags: { f: { admins: true } } }) - expect(await isFeatureEnabled('f', { userId: 'u1', isAdmin: true })).toBe(true) + expect(await enabled('f', { userId: 'u1', isAdmin: true })).toBe(true) expect(mockIsPlatformAdmin).not.toHaveBeenCalled() }) it('resolves to false without querying when userId is absent', async () => { withAppConfig({ flags: { f: { admins: true } } }) - expect(await isFeatureEnabled('f', { orgId: 'o1' })).toBe(false) + expect(await enabled('f', { orgId: 'o1' })).toBe(false) expect(mockIsPlatformAdmin).not.toHaveBeenCalled() }) it('does not query when an earlier clause already matched', async () => { withAppConfig({ flags: { f: { enabled: true, admins: true } } }) - expect(await isFeatureEnabled('f', { userId: 'u1' })).toBe(true) + expect(await enabled('f', { userId: 'u1' })).toBe(true) withAppConfig({ flags: { g: { userIds: ['u1'], admins: true } } }) - expect(await isFeatureEnabled('g', { userId: 'u1' })).toBe(true) + expect(await enabled('g', { userId: 'u1' })).toBe(true) expect(mockIsPlatformAdmin).not.toHaveBeenCalled() }) it('does not query when the rule has no admins clause', async () => { withAppConfig({ flags: { f: { userIds: ['u2'] } } }) - expect(await isFeatureEnabled('f', { userId: 'u1' })).toBe(false) + expect(await enabled('f', { userId: 'u1' })).toBe(false) expect(mockIsPlatformAdmin).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 1630c5610db..d40a15f9a6d 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -46,14 +46,24 @@ export interface FeatureFlagContext { * admin access from a code literal. To add a flag, register its name and the secret * to fall back on. */ -const FEATURE_FLAG_FALLBACKS: Record string | boolean | number | undefined> = { +type FallbackSecret = () => string | boolean | number | undefined + +const FEATURE_FLAG_FALLBACKS = { // 'new-canvas': () => env.NEW_CANVAS_ENABLED, -} +} satisfies Record + +/** + * The closed set of known feature flags. Derived from the fallback registry, so a + * flag cannot exist — or be checked — without a mandatory fallback secret. + */ +export type FeatureFlagName = keyof typeof FEATURE_FLAG_FALLBACKS /** Build the fallback document from each flag's secret. Truthy secret ⇒ enabled. */ function fallbackFlags(): FeatureFlagsConfig { const flags: Record = {} - for (const [name, readSecret] of Object.entries(FEATURE_FLAG_FALLBACKS)) { + for (const [name, readSecret] of Object.entries(FEATURE_FLAG_FALLBACKS) as Array< + [string, FallbackSecret] + >) { flags[name] = { enabled: isTruthy(readSecret()) } } return { flags } @@ -145,7 +155,7 @@ export async function getFeatureFlags(): Promise { /** Resolve a single flag for a context. Admin status is resolved internally from `userId`. */ export async function isFeatureEnabled( - flag: string, + flag: FeatureFlagName, ctx: FeatureFlagContext = {} ): Promise { const { flags } = await getFeatureFlags() From 4b5adb2206031b58d1a2e218931c7f2e7471b42b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 18:35:39 -0700 Subject: [PATCH 5/6] =?UTF-8?q?improvement(feature-flags):=20single=20FEAT?= =?UTF-8?q?URE=5FFLAGS=20registry=20=E2=80=94=20each=20entry=20defines=20n?= =?UTF-8?q?ame,=20description,=20and=20fallback=20in=20one=20place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/commands/add-feature-flag.md | 13 +++++--- .cursor/commands/add-feature-flag.md | 13 +++++--- apps/sim/lib/core/config/feature-flags.ts | 37 +++++++++++++++++------ 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/.claude/commands/add-feature-flag.md b/.claude/commands/add-feature-flag.md index b1fcb99340a..fb7f8ddcbce 100644 --- a/.claude/commands/add-feature-flag.md +++ b/.claude/commands/add-feature-flag.md @@ -31,15 +31,18 @@ Critically, **none of this is expressible in code** — gating (especially `admi ## Steps -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: +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): ```ts - const FEATURE_FLAG_FALLBACKS = { - '': () => env., + const FEATURE_FLAGS = { + '': { + description: '', + fallback: () => env., + }, } ``` - Add `` 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. + Add `` to `apps/sim/lib/core/config/env.ts` (and the deployment's secret store). Do **not** add org/user/admin defaults here — that gating exists only in AppConfig. Adding the entry makes `` a valid `FeatureFlagName`. 2. **Gate the call site.** Call `isFeatureEnabled` with whatever ids you have — admin status is resolved internally, so callers never pass it: @@ -59,7 +62,7 @@ Critically, **none of this is expressible in code** — gating (especially `admi 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. -5. **Clean up after rollout.** When the feature ships to everyone, delete the flag from `FEATURE_FLAG_FALLBACKS`, the `` env entry, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems. +5. **Clean up after rollout.** When the feature ships to everyone, delete the flag's entry from `FEATURE_FLAGS`, the `` env entry, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems. ## Notes diff --git a/.cursor/commands/add-feature-flag.md b/.cursor/commands/add-feature-flag.md index 81618dd4bc3..dd69619b17b 100644 --- a/.cursor/commands/add-feature-flag.md +++ b/.cursor/commands/add-feature-flag.md @@ -26,15 +26,18 @@ Critically, **none of this is expressible in code** — gating (especially `admi ## Steps -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: +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): ```ts - const FEATURE_FLAG_FALLBACKS = { - '': () => env., + const FEATURE_FLAGS = { + '': { + description: '', + fallback: () => env., + }, } ``` - Add `` 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. + Add `` to `apps/sim/lib/core/config/env.ts` (and the deployment's secret store). Do **not** add org/user/admin defaults here — that gating exists only in AppConfig. Adding the entry makes `` a valid `FeatureFlagName`. 2. **Gate the call site.** Call `isFeatureEnabled` with whatever ids you have — admin status is resolved internally, so callers never pass it: @@ -54,7 +57,7 @@ Critically, **none of this is expressible in code** — gating (especially `admi 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. -5. **Clean up after rollout.** When the feature ships to everyone, delete the flag from `FEATURE_FLAG_FALLBACKS`, the `` env entry, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems. +5. **Clean up after rollout.** When the feature ships to everyone, delete the flag's entry from `FEATURE_FLAGS`, the `` env entry, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems. ## Notes diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index d40a15f9a6d..bb9dbc81cd7 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -46,25 +46,42 @@ export interface FeatureFlagContext { * admin access from a code literal. To add a flag, register its name and the secret * to fall back on. */ -type FallbackSecret = () => string | boolean | number | undefined +/** + * The single definition of a feature flag. Everything about a flag lives in one + * place: its name (the registry key), a human-readable `description`, and the + * `fallback` secret consulted when AppConfig isn't the source of truth (truthy ⇒ on + * globally). + * + * Gating by org/user/admin is deliberately NOT part of a definition — it lives only + * in the hosted AppConfig document, so no environment can grant access from a code + * literal. + */ +interface FeatureFlagDefinition { + description: string + fallback: () => string | boolean | number | undefined +} -const FEATURE_FLAG_FALLBACKS = { - // 'new-canvas': () => env.NEW_CANVAS_ENABLED, -} satisfies Record +/** The single registry of known flags. To add a flag, add one entry here. */ +const FEATURE_FLAGS = { + // 'new-canvas': { + // description: 'New canvas renderer', + // fallback: () => env.NEW_CANVAS_ENABLED, + // }, +} satisfies Record /** - * The closed set of known feature flags. Derived from the fallback registry, so a - * flag cannot exist — or be checked — without a mandatory fallback secret. + * The closed set of known feature flags. Derived from the registry, so a flag + * cannot exist — or be checked — without a definition (and its mandatory fallback). */ -export type FeatureFlagName = keyof typeof FEATURE_FLAG_FALLBACKS +export type FeatureFlagName = keyof typeof FEATURE_FLAGS /** Build the fallback document from each flag's secret. Truthy secret ⇒ enabled. */ function fallbackFlags(): FeatureFlagsConfig { const flags: Record = {} - for (const [name, readSecret] of Object.entries(FEATURE_FLAG_FALLBACKS) as Array< - [string, FallbackSecret] + for (const [name, def] of Object.entries(FEATURE_FLAGS) as Array< + [string, FeatureFlagDefinition] >) { - flags[name] = { enabled: isTruthy(readSecret()) } + flags[name] = { enabled: isTruthy(def.fallback()) } } return { flags } } From c82faeb556388185cc4819765442c7f50d100dbc Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 18:39:15 -0700 Subject: [PATCH 6/6] improvement(feature-flags): fallback is the env secret key (keyof typeof env), resolved to a boolean --- .claude/commands/add-feature-flag.md | 4 ++-- .cursor/commands/add-feature-flag.md | 4 ++-- apps/sim/lib/core/config/feature-flags.ts | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.claude/commands/add-feature-flag.md b/.claude/commands/add-feature-flag.md index fb7f8ddcbce..74670ede566 100644 --- a/.claude/commands/add-feature-flag.md +++ b/.claude/commands/add-feature-flag.md @@ -37,12 +37,12 @@ Critically, **none of this is expressible in code** — gating (especially `admi const FEATURE_FLAGS = { '': { description: '', - fallback: () => env., + fallback: '', }, } ``` - Add `` to `apps/sim/lib/core/config/env.ts` (and the deployment's secret store). Do **not** add org/user/admin defaults here — that gating exists only in AppConfig. Adding the entry makes `` a valid `FeatureFlagName`. + `fallback` is the env/secret key (typed as `keyof typeof env`), so add `` 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 `` a valid `FeatureFlagName`. 2. **Gate the call site.** Call `isFeatureEnabled` with whatever ids you have — admin status is resolved internally, so callers never pass it: diff --git a/.cursor/commands/add-feature-flag.md b/.cursor/commands/add-feature-flag.md index dd69619b17b..fc3dba41e46 100644 --- a/.cursor/commands/add-feature-flag.md +++ b/.cursor/commands/add-feature-flag.md @@ -32,12 +32,12 @@ Critically, **none of this is expressible in code** — gating (especially `admi const FEATURE_FLAGS = { '': { description: '', - fallback: () => env., + fallback: '', }, } ``` - Add `` to `apps/sim/lib/core/config/env.ts` (and the deployment's secret store). Do **not** add org/user/admin defaults here — that gating exists only in AppConfig. Adding the entry makes `` a valid `FeatureFlagName`. + `fallback` is the env/secret key (typed as `keyof typeof env`), so add `` 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 `` a valid `FeatureFlagName`. 2. **Gate the call site.** Call `isFeatureEnabled` with whatever ids you have — admin status is resolved internally, so callers never pass it: diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index bb9dbc81cd7..b4018581bba 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -58,14 +58,15 @@ export interface FeatureFlagContext { */ interface FeatureFlagDefinition { description: string - fallback: () => string | boolean | number | undefined + /** Env/secret key consulted when AppConfig isn't the source of truth. Truthy ⇒ on. */ + fallback: keyof typeof env } /** The single registry of known flags. To add a flag, add one entry here. */ const FEATURE_FLAGS = { // 'new-canvas': { // description: 'New canvas renderer', - // fallback: () => env.NEW_CANVAS_ENABLED, + // fallback: 'NEW_CANVAS_ENABLED', // }, } satisfies Record @@ -81,7 +82,7 @@ function fallbackFlags(): FeatureFlagsConfig { for (const [name, def] of Object.entries(FEATURE_FLAGS) as Array< [string, FeatureFlagDefinition] >) { - flags[name] = { enabled: isTruthy(def.fallback()) } + flags[name] = { enabled: isTruthy(env[def.fallback]) } } return { flags } }