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 ungated = new TreeSet<>(); @@ -134,6 +136,7 @@ class ProcessorEndpointSurfaceTest { @Test void everyDeclaredProcessorPathIsActuallyClaimedBySomeController() throws Exception { + assumeTrue(proprietaryOnClasspath(), SKIP_REASON); // A prefix nobody serves means the list has drifted from the code, and the guard above // would pass vacuously for that entry. Set allPaths = new LinkedHashSet<>(); @@ -158,6 +161,19 @@ class ProcessorEndpointSurfaceTest { () -> "declared Processor paths that no controller maps any more: " + unclaimed); } + private static final String SKIP_REASON = + "core flavour builds without :proprietary, so it maps none of these paths"; + + /** app/core/build.gradle only puts :proprietary on the classpath outside the core flavour. */ + private static boolean proprietaryOnClasspath() { + try { + Class.forName("stirling.software.proprietary.policy.controller.PolicyController"); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + private static boolean isProcessorPath(String path) { return PROCESSOR_EXACT_PATHS.contains(path) || PROCESSOR_PATH_PREFIXES.stream().anyMatch(path::startsWith); @@ -227,8 +243,9 @@ class ProcessorEndpointSurfaceTest { controllers.add(type); } } + int floor = proprietaryOnClasspath() ? 40 : 20; assertTrue( - controllers.size() > 40, + controllers.size() > floor, "scan found only " + controllers.size() + " controllers - is it wired?"); return controllers; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java index 773f8e083e..074c97abea 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java @@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.configuration.ProcessorFeature; import stirling.software.common.model.enumeration.Role; import stirling.software.proprietary.access.model.AccessPermission; import stirling.software.proprietary.access.model.DefaultAccessPolicy; @@ -36,17 +37,12 @@ public class ResourceAccessService { @Value("${security.portal.defaultAccess:ADMINS_AND_TEAM_LEADS}") private DefaultAccessPolicy portalDefaultPolicy; - // Initialised on: @Value lands after field init, so a directly-constructed instance must not - // fall to Java's false and lock everyone out of the portal. - @Value("${processor.enabled:true}") - private boolean processorEnabled = true; - // ---- public checks ---- /** Whether the user may use the portal / processor. */ public boolean canAccessPortal(User user) { // Editor-only deployment: nobody reaches the portal, not even an admin. - if (!processorEnabled) { + if (!ProcessorFeature.ENABLED) { return false; } return canUseResource(ResourceType.PORTAL, "", null, portalDefaultPolicy, user); @@ -59,7 +55,7 @@ public class ResourceAccessService { */ public Set usersWithPortalAccess( Collection users, Set activeTeamLeaderUserIds) { - if (!processorEnabled) { + if (!ProcessorFeature.ENABLED) { return Set.of(); } Set grantedPrincipals = new HashSet<>(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalApiKeysController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalApiKeysController.java index 425356b3f7..38345f9337 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalApiKeysController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalApiKeysController.java @@ -24,7 +24,6 @@ import stirling.software.proprietary.security.service.ApiKeyManagementService; * keys. Replaces the former portal-only mock endpoint. Not gated behind an Enterprise license - API * keys are a core auth feature available on every self-hosted instance. */ -// Serves the portal only, and an editor-only server has no portal to serve. @ProprietaryUiDataApi @ConditionalOnProcessor @RequiredArgsConstructor diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java index a2e92bd7a1..8e42f9324d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java @@ -25,7 +25,6 @@ import stirling.software.proprietary.service.PortalDocumentsService; * resolved per deployment - self-hosted portal users see the whole server, SaaS users see their * team (see {@link PortalDocumentsScopeResolver}). */ -// Serves the portal only, and an editor-only server has no portal to serve. @ProprietaryUiDataApi @ConditionalOnProcessor @RequiredArgsConstructor diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalInfraAuditController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalInfraAuditController.java index 7ee57d571f..47c50e0142 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalInfraAuditController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalInfraAuditController.java @@ -18,7 +18,6 @@ import stirling.software.proprietary.security.config.EnterpriseEndpoint; import stirling.software.proprietary.service.PortalInfraAuditService; /** Serves the Infrastructure → Audit tab from real audit data, scoped and cached per caller. */ -// Serves the portal only, and an editor-only server has no portal to serve. @ProprietaryUiDataApi @ConditionalOnProcessor @RequiredArgsConstructor diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/ProcessorConditionalTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ProcessorConditionalTest.java index 8aed069817..722228e2a8 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/ProcessorConditionalTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ProcessorConditionalTest.java @@ -18,17 +18,18 @@ import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.MapPropertySource; import org.springframework.core.env.StandardEnvironment; import org.springframework.core.type.filter.AssignableTypeFilter; import org.springframework.data.repository.Repository; import stirling.software.common.annotations.ConditionalOnProcessor; +import stirling.software.common.configuration.ProcessorFeature; /** - * Guards {@code processor.enabled}: every component under the Processor's two packages must carry - * {@link ConditionalOnProcessor}, so an editor-only server starts none of them. + * Guards {@link ProcessorFeature#ENABLED}: every component under the Processor's two packages must + * carry {@link ConditionalOnProcessor}, so an editor-only server starts none of them. * *

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