From bf1ba5c1ed81688be9f3c0f59ebce9fd4d3720a2 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:26:47 +0100 Subject: [PATCH] Hardcode the Processor flag instead of a runtime property --- .gitignore | 1 + .taskfiles/backend.yml | 16 +----- .taskfiles/frontend.yml | 2 +- Taskfile.yml | 9 ++- .../annotations/ConditionalOnProcessor.java | 17 +++--- .../configuration/ConfigInitializer.java | 17 ------ .../configuration/ProcessorFeature.java | 26 +++++++++ .../common/model/ApplicationProperties.java | 31 ---------- .../software/common/util/RequestUriUtils.java | 27 +-------- .../common/util/RequestUriUtilsTest.java | 28 --------- app/core/build.gradle | 4 +- .../controller/api/misc/ConfigController.java | 4 +- .../src/main/resources/settings.yml.template | 4 -- .../config/ProcessorEndpointSurfaceTest.java | 27 +++++++-- .../access/service/ResourceAccessService.java | 10 +--- .../api/PortalApiKeysController.java | 1 - .../api/PortalDocumentsController.java | 1 - .../api/PortalInfraAuditController.java | 1 - .../policy/ProcessorConditionalTest.java | 52 ++++++++--------- docker/embedded/Dockerfile | 4 +- frontend/editor/src/core/api/config.ts | 4 +- .../WatchedFoldersRegistration.tsx | 4 ++ .../compose/docker-compose-editor-only.yml | 57 ++----------------- 23 files changed, 113 insertions(+), 234 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/configuration/ProcessorFeature.java create mode 100644 frontend/editor/src/editoronly/components/watchedFolders/WatchedFoldersRegistration.tsx diff --git a/.gitignore b/.gitignore index 1290056e05..9dbb550210 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ watchedFolders/ # The rule above targets the app's runtime watched-folders working dir, but it # also matches this frontend source component dir; keep the source tracked. !frontend/editor/src/proprietary/components/watchedFolders/ +!frontend/editor/src/editoronly/components/watchedFolders/ clientWebUI/ policy-webhook-spool/ # Scratch dir used by local fixture-regeneration runs (see diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index f47a9fb92a..08a12b9535 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -26,7 +26,6 @@ tasks: AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}' - PROCESSOR_ENABLED: '{{.PROCESSOR_ENABLED}}' dev:proprietary: desc: "Start backend dev server in proprietary mode" @@ -41,25 +40,14 @@ tasks: AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}' SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}' - # Empty leaves the Processor on, matching a stock install. - PROCESSOR_ENABLED: '{{.PROCESSOR_ENABLED | default ""}}' env: SERVER_PORT: '{{.PORT}}' cmds: - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .PROCESSOR_ENABLED}}PROCESSOR_ENABLED={{.PROCESSOR_ENABLED}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"' + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"' platforms: [windows] - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .PROCESSOR_ENABLED}}PROCESSOR_ENABLED={{.PROCESSOR_ENABLED}} {{end}}./gradlew :stirling-pdf:bootRun' + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun' platforms: [linux, darwin] - dev:editoronly: - desc: "Start backend dev server with the Processor off (editor-only deployment)" - cmds: - - task: dev:proprietary - vars: - PORT: '{{.PORT}}' - SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}' - PROCESSOR_ENABLED: "false" - dev:bundled: desc: "Clean + bootRun with frontend bundled into the backend (single :8080 server)" ignore_error: true diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 641d484c28..8ab725fead 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -221,7 +221,7 @@ tasks: - '{{if .PREVIEW}}VITE_BUILD_FOR_PREVIEW=1 {{end}}npx vite build editor --mode proprietary' build:editoronly: - desc: "Build for editor-only mode (no Processor; pair with PROCESSOR_ENABLED=false)" + desc: "Build for editor-only mode (pair with ProcessorFeature.ENABLED=false)" deps: [prepare] cmds: - npx vite build editor --mode editoronly diff --git a/Taskfile.yml b/Taskfile.yml index 217cb3aa73..55dc585525 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -81,20 +81,19 @@ tasks: BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' OPEN: "true" - # One task, because the two halves are one deployment shape: a backend with the - # Processor off served by an editor build that has no Processor code in it. + # The backend half is compiled in, so this only picks the editor bundle; flip + # ProcessorFeature.ENABLED and rebuild to move the backend with it. dev:editoronly: - desc: "Start an editor-only deployment: backend with PROCESSOR_ENABLED=false + editoronly editor" + desc: "Start the editoronly editor build (backend follows ProcessorFeature.ENABLED)" vars: PORTS: sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}' BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}' deps: - - task: backend:dev:editoronly + - task: backend:dev vars: PORT: '{{.BACKEND_PORT}}' - # Inherited from settings.yml unless you pass SECURITY_ENABLELOGIN=true. SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}' - task: frontend:dev:editoronly vars: diff --git a/app/common/src/main/java/stirling/software/common/annotations/ConditionalOnProcessor.java b/app/common/src/main/java/stirling/software/common/annotations/ConditionalOnProcessor.java index 87d7c05bf0..7c2020547f 100644 --- a/app/common/src/main/java/stirling/software/common/annotations/ConditionalOnProcessor.java +++ b/app/common/src/main/java/stirling/software/common/annotations/ConditionalOnProcessor.java @@ -6,18 +6,17 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Conditional; + +import stirling.software.common.configuration.ProcessorFeature; /** - * Matches unless {@code processor.enabled=false}, which yields an editor-only deployment. Absent - * the property the Processor is on, so existing installs are unaffected. - * - *
Applied to the Processor's controllers and its background/boot-work beans. Types that - * non-Processor code injects (stores, services, JPA entities, repositories) stay ungated so the - * context still starts with the Processor off. + * Matches while {@link ProcessorFeature#ENABLED} is true. Applied to the Processor's controllers + * and its background/boot-work beans; types that non-Processor code injects (stores, services, JPA + * entities, repositories) stay ungated so the context still starts with the Processor off. */ -@Target({ElementType.TYPE, ElementType.METHOD}) +@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented -@ConditionalOnProperty(name = "processor.enabled", havingValue = "true", matchIfMissing = true) +@Conditional(ProcessorFeature.class) public @interface ConditionalOnProcessor {} diff --git a/app/common/src/main/java/stirling/software/common/configuration/ConfigInitializer.java b/app/common/src/main/java/stirling/software/common/configuration/ConfigInitializer.java index bfb0c09f83..6d3f366b8e 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/ConfigInitializer.java +++ b/app/common/src/main/java/stirling/software/common/configuration/ConfigInitializer.java @@ -80,7 +80,6 @@ public class ConfigInitializer { migrateEnterpriseEditionToPremium(settingsFile, settingsTemplateFile); migrateProFeaturesKeyCasing(settingsFile, settingsTemplateFile); - warnOnStrayProcessorFlag(settingsFile); boolean changesMade = settingsTemplateFile.updateValuesFromYaml(settingsFile, settingsTemplateFile); @@ -103,22 +102,6 @@ public class ConfigInitializer { } } - /** - * The merge below can only rewrite keys the template already defines, so a hand-added - * processor.enabled is erased on this very boot. Nothing can carry it forward - say so loudly - * rather than starting the Processor the admin asked to turn off. - */ - private void warnOnStrayProcessorFlag(YamlHelper yaml) { - Object stray = yaml.getValueByExactKeyPath("processor", "enabled"); - if (stray != null) { - log.warn( - "Ignoring processor.enabled={} in settings.yml - the template merge removes" - + " keys it does not define. Use PROCESSOR_ENABLED or" - + " custom_settings.yml instead.", - stray); - } - } - // TODO: Remove post migration private void migrateEnterpriseEditionToPremium(YamlHelper yaml, YamlHelper template) { if (yaml.getValueByExactKeyPath("enterpriseEdition", "enabled") != null) { diff --git a/app/common/src/main/java/stirling/software/common/configuration/ProcessorFeature.java b/app/common/src/main/java/stirling/software/common/configuration/ProcessorFeature.java new file mode 100644 index 0000000000..c8c1143891 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/configuration/ProcessorFeature.java @@ -0,0 +1,26 @@ +package stirling.software.common.configuration; + +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +/** + * The single source of truth for whether this build ships the Processor - policies, document + * sources, classification, pipelines, triggers, integrations and the {@code /processor} portal. + * + *
Deliberately a constant rather than a property: an editor-only server is a decision made in + * source and shipped, not something a deployment can flip. Flip {@link #ENABLED} to {@code false} + * and rebuild to produce one. + * + *
Doubles as the Spring {@link Condition} behind {@code @ConditionalOnProcessor}, so the beans + * and the plain-Java readers cannot disagree. + */ +public class ProcessorFeature implements Condition { + + public static final boolean ENABLED = true; + + @Override + public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + return ENABLED; + } +} diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 9da585dbb1..216e0c1e66 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -47,7 +47,6 @@ import stirling.software.common.model.oauth2.GoogleProvider; import stirling.software.common.model.oauth2.KeycloakProvider; import stirling.software.common.model.oauth2.Provider; import stirling.software.common.service.SsrfProtectionService.SsrfProtectionLevel; -import stirling.software.common.util.RequestUriUtils; import stirling.software.common.util.ValidationUtils; @Data @@ -82,7 +81,6 @@ public class ApplicationProperties { private InternalApi internalApi = new InternalApi(); private Cluster cluster = new Cluster(); private Policies policies = new Policies(); - private Processor processor = new Processor(); @Bean public PropertySource> dynamicYamlPropertySource(ConfigurableEnvironment environment) @@ -117,15 +115,6 @@ public class ApplicationProperties { return propertySource; } - /** - * RequestUriUtils is static (called per-request from filters), so it can't be injected. Publish - * the flag here, at bean init - long before any request can reach those filters. - */ - @PostConstruct - public void publishProcessorFlag() { - RequestUriUtils.setProcessorEnabled(processor.isEnabled()); - } - /** * Initialize fileUploadLimit from environment variables if not set in settings.yml. Supports * SYSTEMFILEUPLOADLIMIT (format: "100MB") and SYSTEM_MAXFILESIZE (format: "100" in MB). @@ -215,26 +204,6 @@ public class ApplicationProperties { } } - @Data - public static class Processor { - /** - * Whether the Processor - policies, document sources, classification, pipelines, triggers - * and integrations - is available on this server. On by default wherever the proprietary - * module is present. - * - *
Turning this off yields an editor-only deployment: the Processor's beans are never - * created, its endpoints stop being mapped, the {@code /processor} portal is unreachable, - * and the editor hides every Processor affordance. Everything outside the Processor - * (accounts, storage, premium, audit) is untouched, which is what separates this from - * building the {@code core} flavour. - * - *
Deliberately absent from {@code settings.yml.template}: this is a deployment shape - * chosen once, not a setting to browse. Set it with {@code PROCESSOR_ENABLED=false} or in - * {@code custom_settings.yml}, which the template merge never rewrites. - */ - private boolean enabled = true; - } - @Data public static class Policies { /** diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index dc7f67fe46..bfd3f53c67 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -4,18 +4,6 @@ import java.util.regex.Pattern; public class RequestUriUtils { - /** - * Mirror of {@code processor.enabled}, published by ApplicationProperties at bean init. Static - * because every method here is, and every caller invokes them per-request from a filter, long - * after the context has refreshed. Defaults to on so a context that never publishes (tests, - * standalone use) behaves as it always did. - */ - private static volatile boolean processorEnabled = true; - - public static void setProcessorEnabled(boolean enabled) { - processorEnabled = enabled; - } - // Share tokens are 36-char lowercase UUIDs (UUID.randomUUID().toString()); match exactly private static final Pattern SHARE_LINK_PATTERN = Pattern.compile( @@ -88,10 +76,7 @@ public class RequestUriUtils { // cookie, so the server can't authenticate the navigation itself). The // portal gates access via its own auth gate + RequirePortalAccess, and its // data APIs stay protected, so serving the shell pre-auth is safe. - // Not on an editor-only server: there is no portal to bootstrap. - if (processorEnabled - && ("/processor".equals(normalizedUri) - || normalizedUri.startsWith("/processor/"))) { + if ("/processor".equals(normalizedUri) || normalizedUri.startsWith("/processor/")) { return true; } @@ -151,13 +136,6 @@ public class RequestUriUtils { } } - // Editor-only server: the portal route-set isn't mounted, so don't serve its shell. - if (!processorEnabled - && ("/processor".equals(normalizedUri) - || normalizedUri.startsWith("/processor/"))) { - return false; - } - if (normalizedUri.isBlank()) { return false; } @@ -233,8 +211,7 @@ public class RequestUriUtils { || trimmedUri.startsWith("/readiness") || trimmedUri.startsWith( "/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth) - // Policy webhook receiver; the controller only exists with the Processor on. - || (processorEnabled && trimmedUri.startsWith("/api/v1/webhooks/")) + || trimmedUri.startsWith("/api/v1/webhooks/") || trimmedUri.startsWith("/v1/api-docs") // Workflow participant endpoints - access controlled by share tokens, not login || trimmedUri.startsWith("/api/v1/workflow/participant/") diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index f155fedb50..72e5eae9a2 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -88,34 +88,6 @@ class RequestUriUtilsTest { assertTrue(RequestUriUtils.isStaticResource("/app", "/app/processor")); } - @Test - void testProcessorOff_portalShellIsNotServed() { - // Editor-only server: /processor must not be a permitAll static resource, must - // not fall back to the SPA shell, and its webhook receiver must not be public - - // none of those beans exist. Restored in a finally so the flag can't leak. - try { - RequestUriUtils.setProcessorEnabled(false); - assertFalse(RequestUriUtils.isStaticResource("/processor")); - assertFalse(RequestUriUtils.isStaticResource("/processor/users")); - assertFalse(RequestUriUtils.isStaticResource("/app", "/app/processor")); - assertFalse(RequestUriUtils.isFrontendRoute("", "/processor")); - assertFalse(RequestUriUtils.isFrontendRoute("", "/processor/policies")); - assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/webhooks/abc", "")); - // Editor routes are untouched. - assertTrue(RequestUriUtils.isFrontendRoute("", "/merge")); - assertTrue(RequestUriUtils.isStaticResource("/css/style.css")); - assertTrue(RequestUriUtils.isPublicAuthEndpoint("/api/v1/auth/login", "")); - } finally { - RequestUriUtils.setProcessorEnabled(true); - } - } - - @Test - void testProcessorOn_webhookReceiverIsPublic() { - // Signature-verified at the controller, so it must bypass login when mounted. - assertTrue(RequestUriUtils.isPublicAuthEndpoint("/api/v1/webhooks/abc", "")); - } - // --- isFrontendRoute tests --- @Test diff --git a/app/core/build.gradle b/app/core/build.gradle index ee9536fa93..ce5c1eddbc 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -176,7 +176,7 @@ def frontendBuildTask = "frontend:build:${frontendMode}" // editoronly strips the Processor from the bundle; the portal IS the Processor's UI. if (frontendMode == 'editoronly' && buildWithPortal) { throw new GradleException("-PfrontendMode=editoronly cannot be combined with -PbuildWithPortal=true. " + - "An editor-only build ships no Processor UI; run the JAR with PROCESSOR_ENABLED=false to match.") + "An editor-only build ships no Processor UI; set ProcessorFeature.ENABLED=false to match.") } // Workspace root holds package.json and node_modules (shared across editor / @@ -287,7 +287,7 @@ tasks.register('npmBuild', Exec) { doFirst { println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/, portal=${buildWithPortal})" if (frontendMode == 'editoronly') { - println " editor-only bundle: run this JAR with PROCESSOR_ENABLED=false, or the server keeps a Processor its UI cannot reach." + println " editor-only bundle: build this JAR with ProcessorFeature.ENABLED=false, or the server keeps a Processor its UI cannot reach." } } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 2693d06f97..9b73dceaf5 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -22,6 +22,7 @@ import stirling.software.SPDF.config.InitialSetup; import stirling.software.SPDF.controller.api.security.TimestampController; import stirling.software.common.annotations.api.ConfigApi; import stirling.software.common.configuration.AppConfig; +import stirling.software.common.configuration.ProcessorFeature; import stirling.software.common.configuration.interfaces.ShowAdminInterface; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.ServerCertificateServiceInterface; @@ -338,8 +339,7 @@ public class ConfigController { // Premium/Enterprise settings configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled()); - // Processor (policies, sources, classification). Off = editor-only server. - configData.put("processorEnabled", applicationProperties.getProcessor().isEnabled()); + configData.put("processorEnabled", ProcessorFeature.ENABLED); // AI Engine settings ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine(); diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 1635b57571..fdfe40b352 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -406,10 +406,6 @@ aiEngine: pdfComment: true # AI-authored PDF comments/annotations classify: true # Automatic document classification/labelling -# To run editor-only (no policies, sources, classification, routing, integrations or webhooks) set -# PROCESSOR_ENABLED=false, or processor.enabled: false in custom_settings.yml. Do NOT put it in this -# file - every boot rewrites settings.yml from the template and drops keys the template lacks. - policies: # Folder automations can read from and write to the directories you allow here, so treat this as a # security boundary. Leave allowedFolderRoots empty (default) to disable folder sources/outputs, diff --git a/app/core/src/test/java/stirling/software/SPDF/config/ProcessorEndpointSurfaceTest.java b/app/core/src/test/java/stirling/software/SPDF/config/ProcessorEndpointSurfaceTest.java index ebbdefecaa..3882d897df 100644 --- a/app/core/src/test/java/stirling/software/SPDF/config/ProcessorEndpointSurfaceTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/config/ProcessorEndpointSurfaceTest.java @@ -2,6 +2,7 @@ package stirling.software.SPDF.config; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.io.IOException; import java.lang.reflect.Method; @@ -27,9 +28,9 @@ import org.springframework.web.bind.annotation.RequestMapping; import stirling.software.common.annotations.ConditionalOnProcessor; /** - * Guards the URL surface of {@code processor.enabled=false}: every endpoint whose path belongs to - * the Processor must sit on a class carrying {@link ConditionalOnProcessor}, so an editor-only - * server never maps it. + * Guards the URL surface of an editor-only build: every endpoint whose path belongs to the + * Processor must sit on a class carrying {@link ConditionalOnProcessor}, so flipping {@code + * ProcessorFeature.ENABLED} unmaps it. * *
Complements {@code ProcessorConditionalTest}, which scans the Processor's two packages. That
* test cannot see a Processor endpoint declared elsewhere — {@code PipelineController} lives in
@@ -83,7 +84,7 @@ class ProcessorEndpointSurfaceTest {
assertTrue(
offenders.isEmpty(),
() ->
- "These endpoints stay mapped with processor.enabled=false. Add"
+ "These endpoints stay mapped on an editor-only build. Add"
+ " @ConditionalOnProcessor to the controller, or - if the endpoint"
+ " is genuinely not part of the Processor - narrow the path lists"
+ " in this test:\n - "
@@ -106,6 +107,7 @@ class ProcessorEndpointSurfaceTest {
@Test
void nonProcessorEndpointsInSharedNamespacesStayMapped() throws Exception {
+ assumeTrue(proprietaryOnClasspath(), SKIP_REASON);
// If a prefix ever swallowed one of these namespaces, editor features would vanish from an
// editor-only server - the exact opposite of what the flag promises.
Set This scans rather than hardcoding a list, so a component added later fails here instead of
* silently shipping on a server that asked for no Processor. Anything that genuinely must survive
@@ -76,30 +77,31 @@ class ProcessorConditionalTest {
}
@Test
- void theGateReadsProcessorEnabled() {
+ void theGateReadsTheCompileTimeConstant() {
// The annotation is the single definition of the flag - assert its wiring directly,
// since every other test here only asserts the annotation is present.
- ConditionalOnProperty conditional =
- ConditionalOnProcessor.class.getAnnotation(ConditionalOnProperty.class);
- assertNotNull(conditional, "@ConditionalOnProcessor must be a @ConditionalOnProperty");
- assertTrue(
- Arrays.asList(conditional.name()).contains("processor.enabled")
- || Arrays.asList(conditional.value()).contains("processor.enabled"),
- "@ConditionalOnProcessor must gate on processor.enabled");
- assertEquals("true", conditional.havingValue(), "must require processor.enabled=true");
- assertTrue(conditional.matchIfMissing(), "the Processor must stay on by default");
+ Conditional conditional = ConditionalOnProcessor.class.getAnnotation(Conditional.class);
+ assertNotNull(conditional, "@ConditionalOnProcessor must be a @Conditional");
+ assertEquals(
+ List.of(ProcessorFeature.class),
+ Arrays.asList(conditional.value()),
+ "@ConditionalOnProcessor must be driven by ProcessorFeature");
+ assertEquals(
+ ProcessorFeature.ENABLED,
+ new ProcessorFeature().matches(null, null),
+ "the condition must report exactly what the constant says");
}
@Test
- void stacksWithAnotherConditionalOnProperty() {
+ void stacksWithAnotherCondition() {
// TelegramPipelineBot carries both this gate and its own @ConditionalOnProperty. Two
- // @ConditionalOnProperty sets on one class is subtle enough to prove rather than assume:
- // both must have to pass, not just the last one read.
- assertTrue(hasBean("feature.enabled=true", "processor.enabled=true"), "both on -> present");
- assertTrue(
- !hasBean("feature.enabled=true", "processor.enabled=false"),
- "processor off must veto even with the feature on");
- assertTrue(!hasBean("processor.enabled=true"), "the other condition must still apply");
+ // conditions on one class is subtle enough to prove rather than assume: both must pass.
+ assertEquals(
+ ProcessorFeature.ENABLED,
+ hasBean("feature.enabled=true"),
+ "with the feature on, presence must track the constant");
+ assertTrue(!hasBean("feature.enabled=false"), "the other condition must still apply");
+ assertTrue(!hasBean(), "an absent property must still veto");
}
/** By type, not name: a nested @Configuration gets an outer-class-qualified bean name. */
@@ -182,13 +184,9 @@ class ProcessorConditionalTest {
return source.getLocation().getPath().replace('\\', '/').contains("/classes/java/test");
}
- /** processor.enabled=true, else the scanner's condition evaluator hides the gated classes. */
+ /** The scanner evaluates conditions; ProcessorFeature answers from the constant. */
private static StandardEnvironment environmentWithProcessorOn() {
- StandardEnvironment environment = new StandardEnvironment();
- environment
- .getPropertySources()
- .addFirst(new MapPropertySource("test", Map.of("processor.enabled", "true")));
- return environment;
+ return new StandardEnvironment();
}
private static Class> loadClass(String name) {
diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile
index 2b0bc1f516..24df398702 100644
--- a/docker/embedded/Dockerfile
+++ b/docker/embedded/Dockerfile
@@ -47,8 +47,8 @@ ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
# Embed the admin portal app at /portal. Set true by the deploy workflow when the
# portal or AI layers change; defaults false so normal builds skip the extra app.
ARG BUILD_PORTAL=false
-# Vite build mode. Empty keeps the flavour-derived default; "editoronly" builds an
-# editor with no Processor code, which must be run with PROCESSOR_ENABLED=false.
+# Vite build mode. Empty keeps the flavour-derived default; "editoronly" drops the
+# Processor from the bundle and pairs with ProcessorFeature.ENABLED=false.
ARG FRONTEND_MODE=
# Bundle only the JPDFium native for this image's target arch.
diff --git a/frontend/editor/src/core/api/config.ts b/frontend/editor/src/core/api/config.ts
index b65d46c3c1..b7b3d20080 100644
--- a/frontend/editor/src/core/api/config.ts
+++ b/frontend/editor/src/core/api/config.ts
@@ -9,7 +9,9 @@ export const DEFAULT_APP_CONFIG: AppConfig = { enableLogin: true };
/** Earliest point the Processor flag is known — feed the non-hook snapshot. */
function publishConfig(config: AppConfig): AppConfig {
- setProcessorEnabled(config.processorEnabled === true);
+ // Only an explicit false turns it off: the 401 fallback carries no flag, and
+ // reading that as "off" would silently stop policy export enforcement.
+ setProcessorEnabled(config.processorEnabled !== false);
return config;
}
diff --git a/frontend/editor/src/editoronly/components/watchedFolders/WatchedFoldersRegistration.tsx b/frontend/editor/src/editoronly/components/watchedFolders/WatchedFoldersRegistration.tsx
new file mode 100644
index 0000000000..d4687accc1
--- /dev/null
+++ b/frontend/editor/src/editoronly/components/watchedFolders/WatchedFoldersRegistration.tsx
@@ -0,0 +1,4 @@
+// Watched folders are driven by the Processor; this build ships none.
+export default function WatchedFoldersRegistration() {
+ return null;
+}
diff --git a/testing/compose/docker-compose-editor-only.yml b/testing/compose/docker-compose-editor-only.yml
index 31af653599..232ce02505 100644
--- a/testing/compose/docker-compose-editor-only.yml
+++ b/testing/compose/docker-compose-editor-only.yml
@@ -1,16 +1,14 @@
# Editor-only deployment: no Processor, no portal.
#
-# The three controls have to agree or the result is incoherent, so they are set
-# together here and nowhere else:
-# PROCESSOR_ENABLED=false backend - the Processor's beans and endpoints
-# FRONTEND_MODE=editoronly build - the editor's Processor modules
-# BUILD_PORTAL=false build - the /processor portal chunk
+# Requires ProcessorFeature.ENABLED=false in source - the backend half is compiled
+# in, not configured. The two build args below drop the matching front-end code:
+# FRONTEND_MODE=editoronly the editor's Processor modules
+# BUILD_PORTAL=false the /processor portal chunk
#
# docker compose -f testing/compose/docker-compose-editor-only.yml up --build
#
-# Add `--profile control` to also bring up a stock Processor-on server on :8080.
-# Absence proves nothing on its own - the control is what makes the editor-only
-# server's missing UI and 404s mean something. It is a second full image build.
+# There is no companion Processor-on service here: the flag is compiled in, so one
+# source tree yields one shape. Compare against a normal build of main instead.
services:
stirling-editor-only:
build:
@@ -37,8 +35,6 @@ services:
- ../../stirling/editor-only/config:/configs:rw
- ../../stirling/editor-only/logs:/logs:rw
environment:
- # The flag under test. Everything else here is ordinary setup.
- PROCESSOR_ENABLED: "false"
DISABLE_ADDITIONAL_FEATURES: "false"
DOCKER_ENABLE_SECURITY: "true"
SECURITY_ENABLELOGIN: "true"
@@ -53,47 +49,6 @@ services:
networks:
- stirling-network
- # Positive control: the same image build with nothing turned off, so the
- # editor-only server above can be diffed against a server that has it all.
- stirling-processor-control:
- profiles: ["control"]
- build:
- context: ../..
- dockerfile: docker/embedded/Dockerfile
- args:
- BUILD_PORTAL: "true"
- container_name: Stirling-PDF-Processor-Control
- restart: unless-stopped
- deploy:
- resources:
- limits:
- memory: 4G
- healthcheck:
- test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
- interval: 5s
- timeout: 10s
- retries: 16
- ports:
- - "8080:8080"
- volumes:
- - ../../stirling/processor-control/data:/usr/share/tessdata:rw
- - ../../stirling/processor-control/config:/configs:rw
- - ../../stirling/processor-control/logs:/logs:rw
- environment:
- DISABLE_ADDITIONAL_FEATURES: "false"
- DOCKER_ENABLE_SECURITY: "true"
- SECURITY_ENABLELOGIN: "true"
- SECURITY_INITIALLOGIN_USERNAME: "admin"
- SECURITY_INITIALLOGIN_PASSWORD: "stirling"
- SYSTEM_DEFAULTLOCALE: en-US
- UI_APPNAME: Stirling-PDF
- UI_HOMEDESCRIPTION: Processor-on control for the editor-only comparison
- UI_APPNAMENAVBAR: Stirling-PDF Processor
- SYSTEM_MAXFILESIZE: "100"
- METRICS_ENABLED: "true"
- networks:
- - stirling-network
-
networks:
stirling-network:
driver: bridge