diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.ts b/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.ts
index fb9201231344..0c21edc81c50 100644
--- a/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.ts
+++ b/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.ts
@@ -57,8 +57,10 @@ export class DotCustomEventHandlerService {
this.dotPropertiesService
.getKeys([FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED])
.subscribe((response) => {
- const contentEditorFeatureFlag =
- response[FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED] === 'true';
+ // Accept native boolean true (current backend) or the legacy string 'true'
+ // (N-1 backend during a rollback window).
+ const val = response[FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED];
+ const contentEditorFeatureFlag = val === true || val === 'true';
if (!this.handlers) {
this.handlers = {
diff --git a/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java b/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java
index b7d1fc0d4a45..3181ca7caf01 100644
--- a/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java
+++ b/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java
@@ -64,4 +64,11 @@ public interface FeatureFlagName {
String FEATURE_FLAG_OPEN_SEARCH_PHASE = "FEATURE_FLAG_OPEN_SEARCH_PHASE";
String FEATURE_FLAG_NEW_BLOCK_EDITOR = "FEATURE_FLAG_NEW_BLOCK_EDITOR";
+
+ /**
+ * Enables the new content editor (Edit Content v2).
+ * Also checked in content-type metadata to opt individual types out.
+ * Frontend equivalent: {@code FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED}.
+ */
+ String FEATURE_FLAG_CONTENT_EDITOR2_ENABLED = "CONTENT_EDITOR2_ENABLED";
}
diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java
index dc8dc61f8c0f..4fe91d4212ac 100644
--- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java
+++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java
@@ -30,6 +30,7 @@
import com.dotcms.rest.WebResource;
import com.dotmarketing.business.Role;
import com.dotmarketing.util.Config;
+import com.dotmarketing.util.Logger;
import org.glassfish.jersey.server.JSONP;
import com.dotcms.rest.ResponseEntityView;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -58,14 +59,42 @@
public class ConfigurationResource implements Serializable {
private final ConfigurationHelper helper;
+ private final WebResource webResource;
private static final String REPORT_ISSUE_INCLUDE_USER_PII = "REPORT_ISSUE_INCLUDE_USER_PII";
+ /**
+ * Feature flag keys in WHITE_LIST that must be serialised as native JSON booleans.
+ * All other WHITE_LIST entries (strings, numbers, lists) are left as-is.
+ *
+ *
Maintenance rule: every key added here MUST also be present in WHITE_LIST.
+ * The wire format is the normalised lowercase string {@code "true"} or {@code "false"} —
+ * frontend callers should compare with {@code === 'true'}. Adding a key here without
+ * also adding it to WHITE_LIST will silently exclude it from the response.
+ */
+ private static final Set BOOLEAN_FEATURE_FLAGS = ImmutableSet.of(
+ FeatureFlagName.FEATURE_FLAG_EXPERIMENTS,
+ FeatureFlagName.DOTFAVORITEPAGE_FEATURE_ENABLE,
+ FeatureFlagName.FEATURE_FLAG_TEMPLATE_BUILDER_2,
+ FeatureFlagName.FEATURE_FLAG_SEO_IMPROVEMENTS,
+ FeatureFlagName.FEATURE_FLAG_SEO_PAGE_TOOLS,
+ FeatureFlagName.FEATURE_FLAG_EDIT_URL_CONTENT_MAP,
+ FeatureFlagName.FEATURE_FLAG_NEW_BINARY_FIELD,
+ FeatureFlagName.FEATURE_FLAG_ANNOUNCEMENTS,
+ FeatureFlagName.FEATURE_FLAG_NEW_EDIT_PAGE,
+ FeatureFlagName.FEATURE_FLAG_UVE_PREVIEW_MODE,
+ FeatureFlagName.FEATURE_FLAG_UVE_TOGGLE_LOCK,
+ FeatureFlagName.FEATURE_FLAG_UVE_STYLE_EDITOR,
+ FeatureFlagName.FEATURE_FLAG_PAGE_SCANNER,
+ FeatureFlagName.FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION,
+ FeatureFlagName.FEATURE_FLAG_NEW_BLOCK_EDITOR,
+ FeatureFlagName.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED);
+
private static final Set WHITE_LIST = ImmutableSet.copyOf(
Config.getStringArrayProperty("CONFIGURATION_WHITE_LIST",
new String[] {"EMAIL_SYSTEM_ADDRESS", "WYSIWYG_IMAGE_URL_PATTERN", "CHARSET","CONTENT_PALETTE_HIDDEN_CONTENT_TYPES", "DEFAULT_CONTAINER",
FeatureFlagName.FEATURE_FLAG_EXPERIMENTS, FeatureFlagName.DOTFAVORITEPAGE_FEATURE_ENABLE, FeatureFlagName.FEATURE_FLAG_TEMPLATE_BUILDER_2,
"SHOW_VIDEO_THUMBNAIL", "EXPERIMENTS_MIN_DURATION", "EXPERIMENTS_MAX_DURATION", "EXPERIMENTS_DEFAULT_DURATION", FeatureFlagName.FEATURE_FLAG_SEO_IMPROVEMENTS,
- FeatureFlagName.FEATURE_FLAG_SEO_PAGE_TOOLS, FeatureFlagName.FEATURE_FLAG_EDIT_URL_CONTENT_MAP, "CONTENT_EDITOR2_ENABLED", "CONTENT_EDITOR2_CONTENT_TYPE",
+ FeatureFlagName.FEATURE_FLAG_SEO_PAGE_TOOLS, FeatureFlagName.FEATURE_FLAG_EDIT_URL_CONTENT_MAP, FeatureFlagName.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED, "CONTENT_EDITOR2_CONTENT_TYPE",
FeatureFlagName.FEATURE_FLAG_NEW_BINARY_FIELD, FeatureFlagName.FEATURE_FLAG_ANNOUNCEMENTS, FeatureFlagName.FEATURE_FLAG_NEW_EDIT_PAGE,
FeatureFlagName.FEATURE_FLAG_UVE_PREVIEW_MODE, FeatureFlagName.FEATURE_FLAG_UVE_TOGGLE_LOCK, FeatureFlagName.FEATURE_FLAG_UVE_STYLE_EDITOR,
FeatureFlagName.FEATURE_FLAG_PAGE_SCANNER,
@@ -83,6 +112,15 @@ private boolean isOnBlackList(final String key) {
*/
public ConfigurationResource() {
this.helper = ConfigurationHelper.INSTANCE;
+ this.webResource = new WebResource();
+ }
+
+ /**
+ * Test constructor — allows injecting a mock {@link WebResource}.
+ */
+ ConfigurationResource(final WebResource webResource) {
+ this.helper = ConfigurationHelper.INSTANCE;
+ this.webResource = webResource;
}
/**
@@ -100,10 +138,9 @@ public ConfigurationResource() {
@Produces({MediaType.APPLICATION_JSON, "application/javascript"})
public final Response getConfigVariables(@Context final HttpServletRequest request,
@Context final HttpServletResponse response,
- @QueryParam("keys") final String keysQuery)
- throws IOException {
+ @QueryParam("keys") final String keysQuery) {
- new WebResource.InitBuilder(request, response)
+ new WebResource.InitBuilder(webResource)
.requiredBackendUser(true)
.requestAndResponse(request, response)
.rejectWhenNoUser(true)
@@ -147,9 +184,45 @@ private Object recoveryFromConfig (final String key) {
return Config.getIntProperty(key.replace("number:", StringPool.BLANK), 0);
}
+ if (BOOLEAN_FEATURE_FLAGS.contains(key)) {
+ return parseBooleanFlag(key);
+ }
+
return Config.getStringProperty(key, "NOT_FOUND");
}
+ /**
+ * Normalises a feature flag property to the canonical lowercase string {@code "true"} or
+ * {@code "false"}, preserving the existing string wire format so that consumers built
+ * against the pre-existing contract continue to work without changes.
+ * Returns the sentinel {@code "NOT_FOUND"} when the key is not defined anywhere
+ * (no .properties entry, no DOT_* env override).
+ *
+ * Accepted truthy values (case-insensitive, whitespace-trimmed): {@code "true"}, {@code "1"}.
+ * Accepted falsy values: {@code "false"}, {@code "0"}, {@code ""}.
+ * Unrecognised values are logged as WARN and normalise to {@code "false"}.
+ */
+ private static Object parseBooleanFlag(final String key) {
+ final String rawValue = Config.getStringProperty(key, null);
+ if (rawValue == null) {
+ return "NOT_FOUND";
+ }
+ final String normalized = rawValue.trim().toLowerCase(Locale.ROOT);
+ switch (normalized) {
+ case "true":
+ case "1":
+ return "true";
+ case "false":
+ case "0":
+ case "":
+ return "false";
+ default:
+ Logger.warn(ConfigurationResource.class,
+ () -> "Feature flag '" + key + "' has unrecognized value '" + rawValue + "'; treating as false.");
+ return "false";
+ }
+ }
+
/**
* Returns the list of system properties that are set through the dotCMS
* configuration files.
diff --git a/dotCMS/src/test/java/com/dotcms/rest/api/v1/system/ConfigurationResourceTest.java b/dotCMS/src/test/java/com/dotcms/rest/api/v1/system/ConfigurationResourceTest.java
new file mode 100644
index 000000000000..a35acb808003
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/rest/api/v1/system/ConfigurationResourceTest.java
@@ -0,0 +1,291 @@
+package com.dotcms.rest.api.v1.system;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+import com.dotcms.featureflag.FeatureFlagName;
+import com.dotcms.rest.InitDataObject;
+import com.dotcms.rest.ResponseEntityView;
+import com.dotcms.rest.WebResource;
+import com.dotmarketing.util.Config;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.ws.rs.core.Response;
+import java.util.Map;
+
+/**
+ * Unit tests for {@link ConfigurationResource}.
+ *
+ *
This class is the single home for all unit-level assertions about
+ * {@code ConfigurationResource}. Add new test methods here as new behaviour
+ * is introduced.
+ *
+ *
Integration-level tests (requiring a running dotCMS instance) live in
+ * {@code dotcms-integration/.../ConfigurationResourceTest.java}.
+ */
+public class ConfigurationResourceTest {
+
+ private static final String FLAG = FeatureFlagName.FEATURE_FLAG_UVE_TOGGLE_LOCK;
+ private static final String UNDEFINED_FLAG = FeatureFlagName.FEATURE_FLAG_UVE_STYLE_EDITOR;
+ private static final String NON_FLAG_KEY = "EMAIL_SYSTEM_ADDRESS";
+ // A key guaranteed never to appear in WHITE_LIST (fixed UUID-shaped string).
+ private static final String UNLISTED_KEY = "TEST_KEY_THAT_WILL_NEVER_BE_WHITELISTED_a1b2c3d4";
+
+ private WebResource webResource;
+ private ConfigurationResource resource;
+ private HttpServletRequest request;
+ private HttpServletResponse response;
+ private MockedStatic mockedConfig;
+
+ // ── Setup / teardown ──────────────────────────────────────────────────────
+
+ @BeforeEach
+ void setUp() {
+ // Open the Config mock BEFORE constructing ConfigurationResource so that
+ // any static initializer that calls Config (WHITE_LIST, and
+ // JVMInfoResource.obfuscatePattern via isOnBlackList) receives a safe
+ // default instead of null. Without this, Pattern.compile(null) inside
+ // JVMInfoResource throws NullPointerException on first class load.
+ //
+ // Default answer: return the caller-supplied default (second argument)
+ // for any getStringProperty / getStringArrayProperty call that is not
+ // individually stubbed by a test.
+ mockedConfig = mockStatic(Config.class);
+ mockedConfig.when(() -> Config.getStringProperty(anyString(), anyString()))
+ .thenAnswer(inv -> inv.getArgument(1));
+ mockedConfig.when(() -> Config.getStringArrayProperty(anyString(), any(String[].class)))
+ .thenAnswer(inv -> inv.getArgument(1));
+
+ webResource = mock(WebResource.class);
+ resource = new ConfigurationResource(webResource);
+ request = mock(HttpServletRequest.class);
+ response = mock(HttpServletResponse.class);
+
+ final InitDataObject initData = mock(InitDataObject.class);
+ when(webResource.init(any(WebResource.InitBuilder.class))).thenReturn(initData);
+ }
+
+ @AfterEach
+ void tearDown() {
+ mockedConfig.close();
+ }
+
+ // ── Truthy variants (AC: "true", "True", "TRUE", " true ", "1" → true) ──
+
+ /**
+ * Method to test: {@link ConfigurationResource#getConfigVariables}
+ * Given scenario: A boolean feature flag is set to lowercase {@code "true"}.
+ * Expected result: Response contains the normalised string {@code "true"}.
+ */
+ @Test
+ void getConfigVariables_flagSetToLowercaseTrue_returnsNativeBooleanTrue() {
+ mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn("true");
+
+ final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG));
+
+ assertEquals("true", entity.get(FLAG));
+ }
+
+ /**
+ * Method to test: {@link ConfigurationResource#getConfigVariables}
+ * Given scenario: A boolean feature flag is set to capitalized {@code "True"} —
+ * the customer-reported reproduction case.
+ * Expected result: Response contains the normalised string {@code "true"}.
+ */
+ @Test
+ void getConfigVariables_flagSetToCapitalizedTrue_returnsNativeBooleanTrue() {
+ mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn("True");
+
+ final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG));
+
+ assertEquals("true", entity.get(FLAG));
+ }
+
+ /**
+ * Method to test: {@link ConfigurationResource#getConfigVariables}
+ * Given scenario: A boolean feature flag is set to all-caps {@code "TRUE"}.
+ * Expected result: Response contains the normalised string {@code "true"}.
+ */
+ @Test
+ void getConfigVariables_flagSetToUppercaseTrue_returnsNativeBooleanTrue() {
+ mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn("TRUE");
+
+ final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG));
+
+ assertEquals("true", entity.get(FLAG));
+ }
+
+ /**
+ * Method to test: {@link ConfigurationResource#getConfigVariables}
+ * Given scenario: A boolean feature flag is set to {@code " true "} with surrounding whitespace.
+ * Expected result: Response contains the normalised string {@code "true"} — whitespace is trimmed.
+ */
+ @Test
+ void getConfigVariables_flagSetToTrueWithWhitespace_returnsNativeBooleanTrue() {
+ mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn(" true ");
+
+ final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG));
+
+ assertEquals("true", entity.get(FLAG));
+ }
+
+ /**
+ * Method to test: {@link ConfigurationResource#getConfigVariables}
+ * Given scenario: A boolean feature flag is set to the numeric shorthand {@code "1"}.
+ * Expected result: Response contains the normalised string {@code "true"}.
+ */
+ @Test
+ void getConfigVariables_flagSetToNumericOne_returnsNativeBooleanTrue() {
+ mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn("1");
+
+ final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG));
+
+ assertEquals("true", entity.get(FLAG));
+ }
+
+ // ── Falsy variants (AC: "false", "False", "FALSE", " false ", "0", "" → false) ──
+
+ /**
+ * Method to test: {@link ConfigurationResource#getConfigVariables}
+ * Given scenario: A boolean feature flag is set to lowercase {@code "false"}.
+ * Expected result: Response contains the normalised string {@code "false"}.
+ */
+ @Test
+ void getConfigVariables_flagSetToLowercaseFalse_returnsNativeBooleanFalse() {
+ mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn("false");
+
+ final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG));
+
+ assertEquals("false", entity.get(FLAG));
+ }
+
+ /**
+ * Method to test: {@link ConfigurationResource#getConfigVariables}
+ * Given scenario: A boolean feature flag is set to all-caps {@code "FALSE"}.
+ * Expected result: Response contains the normalised string {@code "false"}.
+ */
+ @Test
+ void getConfigVariables_flagSetToUppercaseFalse_returnsNativeBooleanFalse() {
+ mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn("FALSE");
+
+ final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG));
+
+ assertEquals("false", entity.get(FLAG));
+ }
+
+ /**
+ * Method to test: {@link ConfigurationResource#getConfigVariables}
+ * Given scenario: A boolean feature flag is set to the numeric shorthand {@code "0"}.
+ * Expected result: Response contains the normalised string {@code "false"}.
+ */
+ @Test
+ void getConfigVariables_flagSetToNumericZero_returnsNativeBooleanFalse() {
+ mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn("0");
+
+ final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG));
+
+ assertEquals("false", entity.get(FLAG));
+ }
+
+ /**
+ * Method to test: {@link ConfigurationResource#getConfigVariables}
+ * Given scenario: A boolean feature flag is set to an empty string.
+ * Expected result: Response contains the normalised string {@code "false"}.
+ */
+ @Test
+ void getConfigVariables_flagSetToEmptyString_returnsNativeBooleanFalse() {
+ mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn("");
+
+ final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG));
+
+ assertEquals("false", entity.get(FLAG));
+ }
+
+ // ── NOT_FOUND sentinel ────────────────────────────────────────────────────
+
+ /**
+ * Method to test: {@link ConfigurationResource#getConfigVariables}
+ * Given scenario: A boolean feature flag key is not defined anywhere on the server
+ * ({@code Config.getStringProperty} returns {@code null}).
+ * Expected result: Response contains the literal string {@code "NOT_FOUND"}, never a boolean —
+ * the frontend uses this sentinel to apply its own enabled-by-default opt-out logic.
+ */
+ @Test
+ void getConfigVariables_flagNotDefined_returnsNotFoundSentinel() {
+ mockedConfig.when(() -> Config.getStringProperty(UNDEFINED_FLAG, null)).thenReturn(null);
+
+ final Map entity = entityMap(
+ resource.getConfigVariables(request, response, UNDEFINED_FLAG));
+
+ assertEquals("NOT_FOUND", entity.get(UNDEFINED_FLAG));
+ }
+
+ // ── Unrecognised value ────────────────────────────────────────────────────
+
+ /**
+ * Method to test: {@link ConfigurationResource#getConfigVariables}
+ * Given scenario: A boolean feature flag has an unrecognized value such as {@code "enabled"}.
+ * Expected result: Response contains the normalised string {@code "false"} — safe default preserved.
+ */
+ @Test
+ void getConfigVariables_flagSetToUnrecognizedValue_returnsNativeBooleanFalse() {
+ mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn("enabled");
+
+ final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG));
+
+ assertEquals("false", entity.get(FLAG));
+ }
+
+ // ── Whitelist filtering ───────────────────────────────────────────────────
+
+ /**
+ * Method to test: {@link ConfigurationResource#getConfigVariables}
+ * Given scenario: The caller requests a key that is not on the whitelist.
+ * Expected result: The key is absent from the response — it is silently excluded.
+ */
+ @Test
+ void getConfigVariables_keyNotOnWhitelist_isExcludedFromResponse() {
+ final Map entity = entityMap(
+ resource.getConfigVariables(request, response, UNLISTED_KEY));
+
+ assertFalse(entity.containsKey(UNLISTED_KEY));
+ }
+
+ // ── Non-boolean whitelisted key ───────────────────────────────────────────
+
+ /**
+ * Method to test: {@link ConfigurationResource#getConfigVariables}
+ * Given scenario: The caller requests a whitelisted key that is not a boolean feature flag
+ * (e.g. {@code EMAIL_SYSTEM_ADDRESS}).
+ * Expected result: The raw string value is returned unchanged — boolean coercion is not applied
+ * to non-flag keys.
+ */
+ @Test
+ void getConfigVariables_nonFlagWhitelistedKey_returnsRawStringValue() {
+ final String testAddress = "admin@example.com";
+ mockedConfig.when(() -> Config.getStringProperty(NON_FLAG_KEY, "NOT_FOUND")).thenReturn(testAddress);
+
+ final Map entity = entityMap(
+ resource.getConfigVariables(request, response, NON_FLAG_KEY));
+
+ assertEquals(testAddress, entity.get(NON_FLAG_KEY));
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ @SuppressWarnings("unchecked")
+ private static Map entityMap(final Response response) {
+ return (Map) ((ResponseEntityView) response.getEntity()).getEntity();
+ }
+}