diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index 5c366af6220..e7db9a30026 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -10,6 +10,7 @@ const { mockFetch, mockIsPlatformAdmin, envRef, flagRef } = vi.hoisted(() => ({ envRef: { APPCONFIG_APPLICATION: 'sim-staging' as string | undefined, APPCONFIG_ENVIRONMENT: 'staging' as string | undefined, + FORKING_ENABLED: undefined as boolean | undefined, }, flagRef: { isAppConfigEnabled: false }, })) @@ -106,6 +107,26 @@ describe('isFeatureEnabled', () => { beforeEach(() => { vi.clearAllMocks() flagRef.isAppConfigEnabled = false + envRef.FORKING_ENABLED = undefined + }) + + describe('workspace-forking flag', () => { + it('falls back to FORKING_ENABLED when AppConfig is disabled', async () => { + envRef.FORKING_ENABLED = undefined + expect(await isFeatureEnabled('workspace-forking', { userId: 'u1', orgId: 'o1' })).toBe(false) + + envRef.FORKING_ENABLED = true + expect(await isFeatureEnabled('workspace-forking', { userId: 'u1', orgId: 'o1' })).toBe(true) + }) + + it('targets specific orgs/users via AppConfig, ignoring the fallback secret', async () => { + envRef.FORKING_ENABLED = undefined + withAppConfig({ 'workspace-forking': { orgIds: ['o1'], userIds: ['u9'] } }) + + expect(await isFeatureEnabled('workspace-forking', { orgId: 'o1' })).toBe(true) + expect(await isFeatureEnabled('workspace-forking', { userId: 'u9' })).toBe(true) + expect(await isFeatureEnabled('workspace-forking', { orgId: 'o2', userId: 'u1' })).toBe(false) + }) }) it('returns false for an unknown flag', async () => { diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 9d0847a7152..6f3a3b4847b 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -105,6 +105,15 @@ const FEATURE_FLAGS = { 'logging session (no user/org context) so an execution never mixes write paths.', fallback: 'REDIS_PROGRESS_MARKERS', }, + 'workspace-forking': { + description: + 'Runtime rollout gate for workspace forking (fork/promote/rollback), layered on top of ' + + 'the existing FORKING_ENABLED / Enterprise-plan gate at the shared assertForkingEnabled ' + + 'choke point. Enforced ONLY where AppConfig is the source of truth (Sim Cloud), so ' + + 'operators can dark-launch forking to specific orgs/users/admins without touching ' + + 'self-hosted/local behaviour. Fallback mirrors FORKING_ENABLED for off-AppConfig reads.', + fallback: 'FORKING_ENABLED', + }, } satisfies Record /** diff --git a/apps/sim/lib/workspaces/fork/lineage/authz.ts b/apps/sim/lib/workspaces/fork/lineage/authz.ts index a7d4d84dcf7..ecbb2c6d8db 100644 --- a/apps/sim/lib/workspaces/fork/lineage/authz.ts +++ b/apps/sim/lib/workspaces/fork/lineage/authz.ts @@ -1,5 +1,6 @@ import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' -import { isBillingEnabled, isForkingEnabled } from '@/lib/core/config/env-flags' +import { isAppConfigEnabled, isBillingEnabled, isForkingEnabled } from '@/lib/core/config/env-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { HttpError } from '@/lib/core/utils/http-error' import { type ForkEdge, resolveForkEdge } from '@/lib/workspaces/fork/lineage/lineage' import { checkWorkspaceAccess, type WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' @@ -9,12 +10,18 @@ import { getWorkspaceCreationPolicy, type WorkspaceCreationPolicy } from '@/lib/ export type PromoteDirection = 'push' | 'pull' /** - * Enterprise-only gate shared by every fork/promote route. On Sim Cloud the gate - * is the Enterprise plan; on self-hosted it's `FORKING_ENABLED`, which 404s when - * unset so a newer image doesn't silently expose forking. Mirrors the data-drains - * gate - this repo gates EE features by plan + env flag, not by directory. + * Gate shared by every fork/promote route. The deployment/entitlement gate is + * unchanged: on Sim Cloud the gate is the Enterprise plan; on self-hosted it's + * `FORKING_ENABLED`, which 404s when unset so a newer image doesn't silently expose + * forking. Mirrors the data-drains gate - this repo gates EE features by plan + env + * flag, not by directory. + * + * Layered on top is the runtime `workspace-forking` flag, a rollout switch enforced + * ONLY where AppConfig is the source of truth (Sim Cloud). It lets us dark-launch + * forking to specific orgs/users/admins without a redeploy; self-hosted/local + * deployments have no AppConfig, so their behaviour is untouched by the flag. */ -async function assertForkingEnabled(organizationId: string | null): Promise { +async function assertForkingEnabled(organizationId: string | null, userId: string): Promise { if (!isBillingEnabled && !isForkingEnabled) { throw new ForkError('Workspace forking is not enabled on this deployment', 404) } @@ -26,6 +33,12 @@ async function assertForkingEnabled(organizationId: string | null): Promise