diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml
index 08a12b9535..f47a9fb92a 100644
--- a/.taskfiles/backend.yml
+++ b/.taskfiles/backend.yml
@@ -26,6 +26,7 @@ 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"
@@ -40,14 +41,25 @@ 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}}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}}{{if .PROCESSOR_ENABLED}}PROCESSOR_ENABLED={{.PROCESSOR_ENABLED}} {{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}}./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}}{{if .PROCESSOR_ENABLED}}PROCESSOR_ENABLED={{.PROCESSOR_ENABLED}} {{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 844306824d..641d484c28 100644
--- a/.taskfiles/frontend.yml
+++ b/.taskfiles/frontend.yml
@@ -154,6 +154,13 @@ tasks:
- task: dev:_run
vars: { MODE: proprietary, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
+ dev:editoronly:
+ desc: "Start frontend dev server in editor-only mode (no Processor)"
+ deps: [prepare]
+ cmds:
+ - task: dev:_run
+ vars: { MODE: editoronly, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
+
dev:saas:
desc: "Start frontend dev server in SaaS mode (SAAS_ENV=dev|staging|prod)"
deps:
@@ -213,6 +220,12 @@ tasks:
cmds:
- '{{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)"
+ deps: [prepare]
+ cmds:
+ - npx vite build editor --mode editoronly
+
build:saas:
desc: "Build for SaaS mode"
deps:
@@ -413,6 +426,13 @@ tasks:
- task: typecheck:_run
vars: { PROJECT: editor/src/proprietary/tsconfig.json }
+ typecheck:editoronly:
+ desc: "Typecheck editor-only build variant"
+ deps: [prepare]
+ cmds:
+ - task: typecheck:_run
+ vars: { PROJECT: editor/src/editoronly/tsconfig.json }
+
typecheck:saas:
desc: "Typecheck SaaS build variant"
deps:
@@ -471,6 +491,7 @@ tasks:
cmds:
- task: typecheck:core
- task: typecheck:proprietary
+ - task: typecheck:editoronly
- task: typecheck:saas
- task: typecheck:desktop
- task: typecheck:cloud
diff --git a/Taskfile.yml b/Taskfile.yml
index 92dcdcc742..217cb3aa73 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -81,6 +81,27 @@ 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.
+ dev:editoronly:
+ desc: "Start an editor-only deployment: backend with PROCESSOR_ENABLED=false + editoronly editor"
+ 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
+ vars:
+ PORT: '{{.BACKEND_PORT}}'
+ # Inherited from settings.yml unless you pass SECURITY_ENABLELOGIN=true.
+ SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
+ - task: frontend:dev:editoronly
+ vars:
+ PORT: '{{.FRONTEND_PORT}}'
+ BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
+ OPEN: "true"
+
dev:portal:
desc: "Start backend + editor; the portal is an admin route at /portal"
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
new file mode 100644
index 0000000000..87d7c05bf0
--- /dev/null
+++ b/app/common/src/main/java/stirling/software/common/annotations/ConditionalOnProcessor.java
@@ -0,0 +1,23 @@
+package stirling.software.common.annotations;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+
+/**
+ * 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.
+ */
+@Target({ElementType.TYPE, ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@ConditionalOnProperty(name = "processor.enabled", havingValue = "true", matchIfMissing = true)
+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 6d3f366b8e..bfb0c09f83 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,6 +80,7 @@ public class ConfigInitializer {
migrateEnterpriseEditionToPremium(settingsFile, settingsTemplateFile);
migrateProFeaturesKeyCasing(settingsFile, settingsTemplateFile);
+ warnOnStrayProcessorFlag(settingsFile);
boolean changesMade =
settingsTemplateFile.updateValuesFromYaml(settingsFile, settingsTemplateFile);
@@ -102,6 +103,22 @@ 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/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
index 216e0c1e66..9da585dbb1 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,6 +47,7 @@ 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
@@ -81,6 +82,7 @@ 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)
@@ -115,6 +117,15 @@ 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).
@@ -204,6 +215,26 @@ 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/FileMonitor.java b/app/common/src/main/java/stirling/software/common/util/FileMonitor.java
index dc0362b356..24c9531f42 100644
--- a/app/common/src/main/java/stirling/software/common/util/FileMonitor.java
+++ b/app/common/src/main/java/stirling/software/common/util/FileMonitor.java
@@ -18,10 +18,14 @@ import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.configuration.RuntimePathConfig;
+// Watches only the pipeline folders for PipelineDirectoryProcessor, so an editor-only server
+// should not hold a WatchService or run its scheduled tick.
@Component
@Slf4j
+@ConditionalOnProcessor
public class FileMonitor {
private final Map path2KeyMapping;
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 bfd3f53c67..dc7f67fe46 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,6 +4,18 @@ 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(
@@ -76,7 +88,10 @@ 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.
- if ("/processor".equals(normalizedUri) || normalizedUri.startsWith("/processor/")) {
+ // Not on an editor-only server: there is no portal to bootstrap.
+ if (processorEnabled
+ && ("/processor".equals(normalizedUri)
+ || normalizedUri.startsWith("/processor/"))) {
return true;
}
@@ -136,6 +151,13 @@ 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;
}
@@ -211,7 +233,8 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/readiness")
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
- || trimmedUri.startsWith("/api/v1/webhooks/")
+ // Policy webhook receiver; the controller only exists with the Processor on.
+ || (processorEnabled && 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 72e5eae9a2..f155fedb50 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,6 +88,34 @@ 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 76828445a3..ee9536fa93 100644
--- a/app/core/build.gradle
+++ b/app/core/build.gradle
@@ -173,6 +173,11 @@ if (buildPrototypes) {
frontendMode = 'proprietary'
}
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.")
+}
// Workspace root holds package.json and node_modules (shared across editor /
// future portal). Editor-specific paths (src, public, dist, tauri) live one
@@ -281,6 +286,9 @@ 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."
+ }
}
}
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 36beb6610c..2693d06f97 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
@@ -338,6 +338,9 @@ 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());
+
// AI Engine settings
ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine();
configData.put("aiEngineEnabled", aiEngineConfig.isEnabled());
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java
index b624434502..6b1d35bfd0 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java
@@ -25,6 +25,7 @@ import stirling.software.SPDF.model.PipelineOperation;
import stirling.software.SPDF.model.PipelineResult;
import stirling.software.SPDF.model.api.HandleDataRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.annotations.api.PipelineApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.PostHogService;
@@ -37,8 +38,11 @@ import tools.jackson.core.JacksonException;
import tools.jackson.databind.DatabindException;
import tools.jackson.databind.ObjectMapper;
+// The pipeline's HTTP entry point. Its other two runners (PipelineDirectoryProcessor,
+// TelegramPipelineBot) are gated too, so an editor-only server runs no pipelines at all.
@PipelineApi
@Slf4j
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class PipelineController {
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java
index 627b1461f6..f5e97020c3 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java
@@ -33,6 +33,7 @@ import stirling.software.SPDF.model.PipelineConfig;
import stirling.software.SPDF.model.PipelineOperation;
import stirling.software.SPDF.model.PipelineResult;
import stirling.software.SPDF.service.ApiDocService;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.PostHogService;
import stirling.software.common.service.ToolMetadataService;
@@ -40,8 +41,11 @@ import stirling.software.common.util.FileReadinessChecker;
import tools.jackson.databind.ObjectMapper;
+// Legacy watched-folder automation: unattended file-triggered execution, so an
+// editor-only server must not run its 60s scan (or create the folders).
@Service
@Slf4j
+@ConditionalOnProcessor
public class PipelineDirectoryProcessor {
private static final int MAX_DIRECTORY_DEPTH = 50; // Prevent excessive recursion
diff --git a/app/core/src/main/java/stirling/software/SPDF/service/telegram/TelegramPipelineBot.java b/app/core/src/main/java/stirling/software/SPDF/service/telegram/TelegramPipelineBot.java
index 3b8d5cff10..34dce0c1c5 100644
--- a/app/core/src/main/java/stirling/software/SPDF/service/telegram/TelegramPipelineBot.java
+++ b/app/core/src/main/java/stirling/software/SPDF/service/telegram/TelegramPipelineBot.java
@@ -39,6 +39,7 @@ import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
@@ -47,9 +48,12 @@ import stirling.software.common.model.ApplicationProperties;
*
* @since 2.2.x
*/
+// Needs the Processor: it drops files in the watched folder and polls the finished folder, both
+// driven by PipelineDirectoryProcessor. Without it every upload would just time out.
@Slf4j
@Component
@ConditionalOnProperty(prefix = "telegram", name = "enabled", havingValue = "true")
+@ConditionalOnProcessor
public class TelegramPipelineBot extends TelegramLongPollingBot {
private static final String CHAT_PRIVATE = "private";
diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template
index fdfe40b352..1635b57571 100644
--- a/app/core/src/main/resources/settings.yml.template
+++ b/app/core/src/main/resources/settings.yml.template
@@ -406,6 +406,10 @@ 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
new file mode 100644
index 0000000000..ebbdefecaa
--- /dev/null
+++ b/app/core/src/test/java/stirling/software/SPDF/config/ProcessorEndpointSurfaceTest.java
@@ -0,0 +1,244 @@
+package stirling.software.SPDF.config;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.lang.reflect.Method;
+import java.security.CodeSource;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.core.annotation.AnnotatedElementUtils;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.core.io.support.ResourcePatternResolver;
+import org.springframework.core.type.classreading.CachingMetadataReaderFactory;
+import org.springframework.core.type.classreading.MetadataReader;
+import org.springframework.core.type.classreading.MetadataReaderFactory;
+import org.springframework.stereotype.Controller;
+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.
+ *
+ * Complements {@code ProcessorConditionalTest}, which scans the Processor's two packages. That
+ * test cannot see a Processor endpoint declared elsewhere — {@code PipelineController} lives in
+ * core and {@code ClassifyLabelController} under {@code proprietary.controller.api}, and both
+ * shipped ungated until this test existed. Anchoring on the URL instead of the package is what
+ * catches those.
+ *
+ *
Lives in {@code :stirling-pdf} (core) because that is the module whose classpath transitively
+ * sees every other module's controllers.
+ */
+class ProcessorEndpointSurfaceTest {
+
+ private static final String SCAN_BASE_PACKAGE = "stirling.software";
+
+ /** Path prefixes owned entirely by the Processor. */
+ private static final List PROCESSOR_PATH_PREFIXES =
+ List.of(
+ "/api/v1/policies",
+ "/api/v1/sources",
+ // also covers /api/v1/integrations
+ "/api/v1/integration",
+ "/api/v1/webhooks",
+ "/api/v1/pipeline",
+ "/api/v1/admin/settings/policies",
+ // Only these two sub-trees of /api/v1/proprietary/ui-data belong to the
+ // portal; its siblings (audit, teams, account, database) serve the editor.
+ "/api/v1/proprietary/ui-data/documents",
+ "/api/v1/proprietary/ui-data/infrastructure");
+
+ /**
+ * Processor endpoints sharing a namespace with non-Processor ones, so they can only be matched
+ * exactly. {@code /api/v1/ai/tools} also holds the create-pdf, math-auditor and pdf-comment
+ * agents, which are editor features and must survive the flag.
+ */
+ private static final List PROCESSOR_EXACT_PATHS =
+ List.of("/api/v1/ai/tools/classify-and-label");
+
+ @Test
+ void everyProcessorEndpointSitsOnAGatedController() throws Exception {
+ Set offenders = new TreeSet<>();
+ for (Class> controller : scanForControllers()) {
+ if (AnnotatedElementUtils.hasAnnotation(controller, ConditionalOnProcessor.class)) {
+ continue;
+ }
+ for (String path : mappedPaths(controller)) {
+ if (isProcessorPath(path)) {
+ offenders.add(path + " (" + controller.getName() + ")");
+ }
+ }
+ }
+ assertTrue(
+ offenders.isEmpty(),
+ () ->
+ "These endpoints stay mapped with processor.enabled=false. Add"
+ + " @ConditionalOnProcessor to the controller, or - if the endpoint"
+ + " is genuinely not part of the Processor - narrow the path lists"
+ + " in this test:\n - "
+ + String.join("\n - ", offenders));
+ }
+
+ /** Namespaces the Processor shares with the editor, and what must survive in each. */
+ private static final Map> SHARED_NAMESPACES =
+ Map.of(
+ "/api/v1/ai/tools/",
+ List.of(
+ "/api/v1/ai/tools/create-pdf-from-html-agent",
+ "/api/v1/ai/tools/math-auditor-agent",
+ "/api/v1/ai/tools/pdf-comment-agent"),
+ "/api/v1/proprietary/ui-data/",
+ List.of(
+ "/api/v1/proprietary/ui-data/account",
+ "/api/v1/proprietary/ui-data/teams",
+ "/api/v1/proprietary/ui-data/audit-events"));
+
+ @Test
+ void nonProcessorEndpointsInSharedNamespacesStayMapped() throws Exception {
+ // 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<>();
+ for (Class> controller : scanForControllers()) {
+ if (AnnotatedElementUtils.hasAnnotation(controller, ConditionalOnProcessor.class)) {
+ continue;
+ }
+ ungated.addAll(mappedPaths(controller));
+ }
+ for (Map.Entry> namespace : SHARED_NAMESPACES.entrySet()) {
+ for (String mustSurvive : namespace.getValue()) {
+ assertTrue(
+ ungated.contains(mustSurvive),
+ () ->
+ mustSurvive
+ + " is an editor endpoint but is gated or gone; the"
+ + " Processor shares "
+ + namespace.getKey()
+ + " with it");
+ assertFalse(
+ isProcessorPath(mustSurvive),
+ () -> mustSurvive + " is claimed as a Processor path but is an editor one");
+ }
+ }
+ }
+
+ @Test
+ void everyDeclaredProcessorPathIsActuallyClaimedBySomeController() throws Exception {
+ // 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<>();
+ for (Class> controller : scanForControllers()) {
+ allPaths.addAll(mappedPaths(controller));
+ }
+ assertTrue(allPaths.size() > 100, "scan found only " + allPaths.size() + " endpoints");
+
+ Set unclaimed = new TreeSet<>();
+ for (String prefix : PROCESSOR_PATH_PREFIXES) {
+ if (allPaths.stream().noneMatch(p -> p.startsWith(prefix))) {
+ unclaimed.add(prefix);
+ }
+ }
+ for (String exact : PROCESSOR_EXACT_PATHS) {
+ if (!allPaths.contains(exact)) {
+ unclaimed.add(exact);
+ }
+ }
+ assertTrue(
+ unclaimed.isEmpty(),
+ () -> "declared Processor paths that no controller maps any more: " + unclaimed);
+ }
+
+ private static boolean isProcessorPath(String path) {
+ return PROCESSOR_EXACT_PATHS.contains(path)
+ || PROCESSOR_PATH_PREFIXES.stream().anyMatch(path::startsWith);
+ }
+
+ /** Class-level base joined with each handler method's own path. */
+ private static Set mappedPaths(Class> controller) {
+ Set paths = new LinkedHashSet<>();
+ RequestMapping base =
+ AnnotatedElementUtils.findMergedAnnotation(controller, RequestMapping.class);
+ List bases = base == null ? List.of("") : pathsOf(base);
+ for (Method method : controller.getDeclaredMethods()) {
+ RequestMapping mapping =
+ AnnotatedElementUtils.findMergedAnnotation(method, RequestMapping.class);
+ if (mapping == null) {
+ continue;
+ }
+ List suffixes = pathsOf(mapping);
+ for (String prefix : bases) {
+ for (String suffix : suffixes) {
+ paths.add(join(prefix, suffix));
+ }
+ }
+ }
+ return paths;
+ }
+
+ private static List pathsOf(RequestMapping mapping) {
+ String[] declared = mapping.path().length > 0 ? mapping.path() : mapping.value();
+ return declared.length > 0 ? List.of(declared) : List.of("");
+ }
+
+ private static String join(String prefix, String suffix) {
+ if (suffix.isEmpty()) {
+ return prefix;
+ }
+ String left = prefix.endsWith("/") ? prefix.substring(0, prefix.length() - 1) : prefix;
+ String right = suffix.startsWith("/") ? suffix : "/" + suffix;
+ return left + right;
+ }
+
+ /**
+ * Every main-source class under {@link #SCAN_BASE_PACKAGE} that Spring would treat as a
+ * controller. Reads class-file metadata first so only the handful of matches get loaded.
+ */
+ private static List> scanForControllers() throws IOException, ClassNotFoundException {
+ ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
+ MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(resolver);
+ Resource[] resources =
+ resolver.getResources(
+ "classpath*:" + SCAN_BASE_PACKAGE.replace('.', '/') + "/**/*.class");
+
+ List> controllers = new ArrayList<>();
+ for (Resource resource : resources) {
+ if (!resource.isReadable()) {
+ continue;
+ }
+ MetadataReader reader = metadataReaderFactory.getMetadataReader(resource);
+ // Meta-annotations included: @RestController and the composed @...Api annotations
+ // (@PipelineApi, @AdminApi) all resolve back to @Controller.
+ if (!reader.getAnnotationMetadata().hasMetaAnnotation(Controller.class.getName())
+ && !reader.getAnnotationMetadata().hasAnnotation(Controller.class.getName())) {
+ continue;
+ }
+ Class> type = Class.forName(reader.getClassMetadata().getClassName());
+ if (!isTestClass(type)) {
+ controllers.add(type);
+ }
+ }
+ assertTrue(
+ controllers.size() > 40,
+ "scan found only " + controllers.size() + " controllers - is it wired?");
+ return controllers;
+ }
+
+ /** Test fixtures live under build/classes/java/test; main code does not. */
+ private static boolean isTestClass(Class> type) {
+ CodeSource source = type.getProtectionDomain().getCodeSource();
+ if (source == null || source.getLocation() == null) {
+ return false;
+ }
+ return source.getLocation().getPath().replace('\\', '/').contains("/classes/java/test");
+ }
+}
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 7642dded85..773f8e083e 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
@@ -36,10 +36,19 @@ 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) {
+ return false;
+ }
return canUseResource(ResourceType.PORTAL, "", null, portalDefaultPolicy, user);
}
@@ -50,6 +59,9 @@ public class ResourceAccessService {
*/
public Set usersWithPortalAccess(
Collection users, Set activeTeamLeaderUserIds) {
+ if (!processorEnabled) {
+ return Set.of();
+ }
Set grantedPrincipals = new HashSet<>();
for (ResourceGrant g :
grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, "")) {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java
index dc422433f0..bebeebbecb 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java
@@ -26,6 +26,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -56,8 +57,11 @@ import tools.jackson.databind.node.ObjectNode;
* pipeline can name it as a step like any other tool. Classification is a thing a pipeline does,
* not a thing only the Classification policy may do.
*/
+// Classification is a Processor feature: this is the classify step's only endpoint, and the
+// editor never opens it interactively (registry marks classify hiddenFromToolList).
@Slf4j
@RestController
+@ConditionalOnProcessor
@RequestMapping("/api/v1/ai/tools")
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
public class ClassifyLabelController {
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 af0f4d055a..425356b3f7 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
@@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest;
import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto;
@@ -23,7 +24,9 @@ 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
public class PortalApiKeysController {
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 f6d094bde4..a2e92bd7a1 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
@@ -10,6 +10,7 @@ import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
import stirling.software.proprietary.audit.PortalAuditScope;
import stirling.software.proprietary.audit.PortalDocumentsScopeResolver;
@@ -24,7 +25,9 @@ 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
@PreAuthorize("@resourceAccess.canUsePortal()")
public class PortalDocumentsController {
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 866c295b8d..7ee57d571f 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
@@ -9,6 +9,7 @@ import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
import stirling.software.proprietary.audit.PortalAuditScope;
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
@@ -17,7 +18,9 @@ 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
@EnterpriseEndpoint
public class PortalInfraAuditController {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/PolicyFailureRecorder.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/PolicyFailureRecorder.java
index eaa8e0f5bd..f2a6584112 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/PolicyFailureRecorder.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/PolicyFailureRecorder.java
@@ -5,6 +5,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
@@ -16,6 +17,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
*/
@Slf4j
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class PolicyFailureRecorder {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionResolver.java
index e29435ff14..182d048c8c 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionResolver.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionResolver.java
@@ -11,6 +11,7 @@ import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.access.model.ResourceType;
import stirling.software.proprietary.access.service.OwnershipService;
import stirling.software.proprietary.integration.model.IntegrationConfig;
@@ -32,6 +33,7 @@ import tools.jackson.databind.ObjectMapper;
* assumption true rather than merely hoped for.
*/
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiConnectionResolver {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiIntegrationValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiIntegrationValidator.java
index 014bacb4a5..32913b5a5e 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiIntegrationValidator.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiIntegrationValidator.java
@@ -6,6 +6,7 @@ import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.cluster.s3.S3Clients;
import stirling.software.proprietary.integration.model.IntegrationType;
@@ -22,6 +23,7 @@ import stirling.software.proprietary.integration.service.IntegrationConfigValida
* cannot close).
*/
@Component
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class ApiIntegrationValidator implements IntegrationConfigValidator {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java
index bb32d85092..8e8eac4d7e 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java
@@ -26,6 +26,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
@@ -63,6 +64,7 @@ import tools.jackson.databind.node.ObjectNode;
*/
@Slf4j
@RestController
+@ConditionalOnProcessor
@RequestMapping("/api/v1/integration")
@RequiredArgsConstructor
@Tag(name = "Integrations", description = "Third-party integration steps.")
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCaller.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCaller.java
index 67171bb5f5..ea89012616 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCaller.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCaller.java
@@ -16,6 +16,7 @@ import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.ApplicationProperties;
import tools.jackson.databind.JsonNode;
@@ -30,6 +31,7 @@ import tools.jackson.databind.ObjectMapper;
*/
@Slf4j
@Service
+@ConditionalOnProcessor
public class ExternalApiCaller {
/**
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/IntegrationStepValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/IntegrationStepValidator.java
index 6118f3b642..d0dcc68af3 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/IntegrationStepValidator.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/IntegrationStepValidator.java
@@ -6,6 +6,7 @@ import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.integration.model.IntegrationType;
import stirling.software.proprietary.policy.engine.PipelineStepValidator;
import stirling.software.proprietary.policy.model.PipelineStep;
@@ -24,6 +25,7 @@ import stirling.software.proprietary.policy.model.PipelineStep;
* validation of a stored policy, and {@code PolicyController}'s ad-hoc gate.
*/
@Component
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class IntegrationStepValidator implements PipelineStepValidator {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java
index 2c92883aee..dcfe194df7 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java
@@ -20,6 +20,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.integration.dto.IntegrationConfigRequest;
import stirling.software.proprietary.integration.dto.IntegrationConfigResponse;
import stirling.software.proprietary.integration.service.IntegrationConfigService;
@@ -27,6 +28,7 @@ import stirling.software.proprietary.security.model.User;
/** CRUD for S3/MCP/API integration configs. Secrets are never returned. */
@RestController
+@ConditionalOnProcessor
@RequestMapping("/api/v1/integrations")
@RequiredArgsConstructor
// Portal-exclusive: server-side portal-access boundary, not just isAuthenticated. Per-config
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewIntegrationValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewIntegrationValidator.java
index 485345e5cd..f1f74e1890 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewIntegrationValidator.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewIntegrationValidator.java
@@ -4,11 +4,13 @@ import java.util.Map;
import org.springframework.stereotype.Component;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.integration.model.IntegrationType;
import stirling.software.proprietary.integration.service.IntegrationConfigValidator;
/** The Purview connection schema, enforced when the config is saved. */
@Component
+@ConditionalOnProcessor
public class PurviewIntegrationValidator implements IntegrationConfigValidator {
@Override
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java
index 666e7e5620..ef90e277cd 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java
@@ -24,6 +24,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -50,6 +51,7 @@ import tools.jackson.databind.node.ObjectNode;
*/
@Slf4j
@RestController
+@ConditionalOnProcessor
@RequestMapping("/api/v1/integration")
@RequiredArgsConstructor
@Tag(name = "Integrations", description = "Third-party integration steps.")
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java
index a29ac2c08f..400d47d87c 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java
@@ -13,6 +13,7 @@ import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
import stirling.software.proprietary.access.model.OwnerScope;
@@ -32,6 +33,7 @@ import tools.jackson.databind.ObjectMapper;
/** CRUD for {@link IntegrationConfig}; delegates ownership and masking to shared services. */
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStore.java
index 65c6b5ed25..e8df520305 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStore.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStore.java
@@ -9,6 +9,7 @@ import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.integration.crypto.CredentialEncryption;
/**
@@ -17,6 +18,7 @@ import stirling.software.proprietary.integration.crypto.CredentialEncryption;
* never decrypt - only {@link #content} does.
*/
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class JpaPolicyAssetStore implements PolicyAssetStore {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetCleaner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetCleaner.java
index 582465e874..aa47cd8ed7 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetCleaner.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetCleaner.java
@@ -14,6 +14,7 @@ import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.PolicyStore;
@@ -30,6 +31,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
*/
@Slf4j
@Service
+@ConditionalOnProcessor
public class PolicyAssetCleaner {
// An upload sits unreferenced until the save that binds it, so the window has to outlast a
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetController.java
index 14349fd64c..b3225ac738 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetController.java
@@ -28,6 +28,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
@@ -40,6 +41,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
* file without anyone re-supplying it. Team-scoped exactly like the policies that reference them.
*/
@RestController
+@ConditionalOnProcessor
@RequestMapping("/api/v1/policies/assets")
@Hidden
@RequiredArgsConstructor
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetResolver.java
index 862d3511a7..53a9441d00 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetResolver.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetResolver.java
@@ -15,6 +15,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyInputs;
@@ -35,6 +36,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
*/
@Slf4j
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class PolicyAssetResolver {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java
index 92ab91e99c..74dfb468cd 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java
@@ -9,6 +9,7 @@ import java.util.Optional;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
@@ -33,6 +34,7 @@ import stirling.software.proprietary.policy.source.SourceStore;
* defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted.
*/
@Component
+@ConditionalOnProcessor
public class FolderAccessGuard {
public static final String FOLDER_TYPE = "folder";
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java
index 2d7061c966..e57e32ab73 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java
@@ -7,6 +7,7 @@ import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.asset.PolicyAsset;
@@ -23,6 +24,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
* enabled; single-user deployments (login disabled) pass every check.
*/
@Component
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class PolicyAccessGuard {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/ClassificationMeterController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/ClassificationMeterController.java
index dc978648b6..4cdcbb9fe8 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/ClassificationMeterController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/ClassificationMeterController.java
@@ -16,6 +16,7 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.audit.AuditContext;
import stirling.software.proprietary.classification.ClassificationRunBiller;
@@ -26,6 +27,7 @@ import stirling.software.proprietary.classification.ClassificationRunBiller;
@Slf4j
@Hidden
@RestController
+@ConditionalOnProcessor
@RequestMapping("/api/v1/policies")
public class ClassificationMeterController {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/FolderAccessSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/FolderAccessSettingsController.java
index 1b39aee106..c4fa270180 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/FolderAccessSettingsController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/FolderAccessSettingsController.java
@@ -9,6 +9,7 @@ import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.annotations.api.AdminApi;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
@@ -20,6 +21,7 @@ import stirling.software.proprietary.policy.config.FolderAccessGuard;
* settings section.
*/
@AdminApi
+@ConditionalOnProcessor
@PreAuthorize("hasRole('ADMIN')")
@RequiredArgsConstructor
public class FolderAccessSettingsController {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java
index 5a5a3018b1..2ac04000cd 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java
@@ -41,6 +41,7 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.cluster.JobStore;
import stirling.software.common.cluster.JobStoreEntry;
import stirling.software.common.model.ApplicationProperties;
@@ -90,6 +91,7 @@ import stirling.software.proprietary.util.SecretMasker;
*/
@Slf4j
@RestController
+@ConditionalOnProcessor
@RequestMapping("/api/v1/policies")
@Hidden
@RequiredArgsConstructor
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java
index f9c0f719ef..fec54d4678 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java
@@ -21,6 +21,7 @@ import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.AutomationRunContext;
import stirling.software.common.service.FileStorage;
@@ -61,6 +62,7 @@ import stirling.software.proprietary.service.DownstreamEntitlementError;
*/
@Slf4j
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class PolicyEngine {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java
index edc906461e..fd883db3ba 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java
@@ -15,6 +15,7 @@ import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.model.PolicyRun;
@@ -28,6 +29,7 @@ import stirling.software.proprietary.policy.model.PolicyRun;
*/
@Slf4j
@Service
+@ConditionalOnProcessor
public class PolicyRunRegistry {
private final Map runs = new ConcurrentHashMap<>();
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java
index d159ebf012..98cb7b3e82 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.input.ResolvedInput;
import stirling.software.proprietary.policy.ledger.ProcessedLedger;
@@ -34,6 +35,7 @@ import stirling.software.proprietary.policy.source.SourceStore;
*/
@Slf4j
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class PolicyRunner {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java
index 1faa016c42..a5a2e1ad26 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java
@@ -8,6 +8,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.tool.ToolDiagnostic;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.service.ToolChainValidator;
@@ -33,6 +34,7 @@ import stirling.software.proprietary.policy.trigger.PolicyTrigger;
* A null trigger is a manual-only input and skips trigger validation.
*/
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class PolicyValidator {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java
index 1e07185ab5..50b8ecc78a 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java
@@ -21,6 +21,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.util.FileReadinessChecker;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.ledger.FolderIdentities;
@@ -40,6 +41,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
*/
@Slf4j
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class FolderInputSource implements InputSource {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java
index 326ccef147..938e9f75dc 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java
@@ -13,6 +13,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.s3.S3Config;
@@ -47,6 +48,7 @@ import software.amazon.awssdk.services.s3.model.S3Object;
*/
@Slf4j
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class S3InputSource implements InputSource {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/WebhookInputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/WebhookInputSource.java
index ff79b5be20..2454415632 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/WebhookInputSource.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/WebhookInputSource.java
@@ -17,6 +17,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.util.FileReadinessChecker;
import stirling.software.proprietary.policy.ledger.FolderIdentities;
import stirling.software.proprietary.policy.model.InputSpec;
@@ -27,6 +28,7 @@ import stirling.software.proprietary.policy.webhook.WebhookSpool;
@Slf4j
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class WebhookInputSource implements InputSource {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java
index 02fce08763..b79bc78441 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java
@@ -14,6 +14,8 @@ import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
+
/**
* Durable {@link ProcessedLedger}; the runtime bean. A fresh claim is a flushed insert so a
* concurrent winner surfaces as a constraint violation; every other transition is a conditional
@@ -23,6 +25,7 @@ import lombok.extern.slf4j.Slf4j;
*/
@Slf4j
@Service
+@ConditionalOnProcessor
public class JpaProcessedLedger implements ProcessedLedger {
private static final int STAMP_CHUNK = 500;
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/JpaCompletedMigrations.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/JpaCompletedMigrations.java
index 853e18be51..80a2013c5b 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/JpaCompletedMigrations.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/JpaCompletedMigrations.java
@@ -8,6 +8,8 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
+
/**
* Durable {@link CompletedMigrations} backed by JPA; the runtime bean. {@code markDone} relies on
* the primary-key uniqueness of {@link CompletedMigration#getId()} to stay safe under a concurrent
@@ -16,6 +18,7 @@ import lombok.extern.slf4j.Slf4j;
*/
@Slf4j
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class JpaCompletedMigrations implements CompletedMigrations {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkConnectionResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkConnectionResolver.java
index 098b31b561..06b2542ea4 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkConnectionResolver.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkConnectionResolver.java
@@ -11,6 +11,7 @@ import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.access.model.ResourceType;
import stirling.software.proprietary.access.service.OwnershipService;
import stirling.software.proprietary.integration.model.IntegrationConfig;
@@ -33,6 +34,7 @@ import tools.jackson.databind.ObjectMapper;
* referencing source was access-checked when it was saved.
*/
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class NetworkConnectionResolver {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkHostGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkHostGuard.java
index 44a174f989..4ae2dd69b3 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkHostGuard.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkHostGuard.java
@@ -7,6 +7,7 @@ import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.ApplicationProperties;
/**
@@ -18,6 +19,7 @@ import stirling.software.common.model.ApplicationProperties;
* save time and before every connect.
*/
@Component
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class NetworkHostGuard {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkInputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkInputSource.java
index 0fdf8d116f..f0f7f8f74a 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkInputSource.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkInputSource.java
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.input.ResolveContext;
import stirling.software.proprietary.policy.input.ResolvedInput;
@@ -29,6 +30,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
*/
@Slf4j
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class NetworkInputSource implements InputSource {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkIntegrationValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkIntegrationValidator.java
index a9ec9cc260..0c9cf43d22 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkIntegrationValidator.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkIntegrationValidator.java
@@ -6,6 +6,7 @@ import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.integration.model.IntegrationType;
import stirling.software.proprietary.integration.service.IntegrationConfigValidator;
@@ -17,6 +18,7 @@ import stirling.software.proprietary.integration.service.IntegrationConfigValida
* that uses the connection is saved ({@link NetworkInputSource#validate}).
*/
@Component
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class NetworkIntegrationValidator implements IntegrationConfigValidator {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/RemoteFileClientFactory.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/RemoteFileClientFactory.java
index fe3f43b1bb..32246d4cce 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/RemoteFileClientFactory.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/RemoteFileClientFactory.java
@@ -6,12 +6,15 @@ import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
+
/**
* Opens a fresh {@link RemoteFileClient} for one {@link NetworkConfig}, dispatched by protocol. The
* host is guarded against private addresses before every connect, since it comes from a portal user
* and each operation opens its own short-lived session (there is no long-lived pool to guard once).
*/
@Component
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class RemoteFileClientFactory {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java
index c06f18a846..d7bd55f9d4 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java
@@ -23,6 +23,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.job.ResultFile;
import stirling.software.proprietary.billing.ContentHasher;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
@@ -39,6 +40,7 @@ import stirling.software.proprietary.policy.model.OutputSpec;
*/
@Slf4j
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class FolderOutputSink implements PolicyOutputSink {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java
index 78531eaa9c..66d3be29ae 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.FileStorage;
import stirling.software.proprietary.policy.model.OutputSpec;
@@ -21,6 +22,7 @@ import stirling.software.proprietary.policy.model.OutputSpec;
* /api/v1/general/files/{fileId}}. Used for manual runs whose results return to the caller.
*/
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class InlineOutputSink implements PolicyOutputSink {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigration.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigration.java
index a93b60d000..8764f5bfda 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigration.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigration.java
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.policy.migration.CompletedMigrations;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.Policy;
@@ -34,6 +35,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
*/
@Slf4j
@Component
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class PolicyInlineOutputMigration {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputResolver.java
index 3e9d804683..37f33d6215 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputResolver.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputResolver.java
@@ -8,6 +8,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.Source;
@@ -24,6 +25,7 @@ import stirling.software.proprietary.policy.source.SourceStore;
*/
@Slf4j
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class PolicyOutputResolver {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java
index 10bf80e278..0bffcddfb6 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java
@@ -21,6 +21,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.job.ResultFile;
import stirling.software.proprietary.policy.ledger.ProcessedLedger;
import stirling.software.proprietary.policy.model.OutputSpec;
@@ -53,6 +54,7 @@ import software.amazon.awssdk.services.s3.model.S3Exception;
*/
@Slf4j
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class S3OutputSink implements PolicyOutputSink {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java
index b2be7b668e..639a09a5a3 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
@@ -26,6 +27,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
* the user-facing Policies page and are excluded; a folder-watch trigger is not a signal.
*/
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class PolicyOverviewService {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java
index bbbb9cb8b1..ad1e028ba8 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
import stirling.software.proprietary.access.model.OwnerScope;
import stirling.software.proprietary.integration.model.IntegrationConfig;
@@ -43,6 +44,7 @@ import tools.jackson.databind.ObjectMapper;
*/
@Slf4j
@Component
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class EmbeddedS3CredentialMigration {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java
index 276ca1b002..b4fa24a8e4 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java
@@ -8,6 +8,7 @@ import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.integration.service.IntegrationConfigUsageCheck;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.Source;
@@ -21,6 +22,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
* stores.
*/
@Component
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class PolicyS3ConnectionUsageCheck implements IntegrationConfigUsageCheck {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionPool.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionPool.java
index 145295d462..b419a7496c 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionPool.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionPool.java
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
import jakarta.annotation.PreDestroy;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.cluster.s3.S3Clients;
@@ -31,6 +32,7 @@ import software.amazon.awssdk.services.s3.S3Configuration;
* users rather than the operator.
*/
@Service
+@ConditionalOnProcessor
public class S3ConnectionPool {
private final ApplicationProperties applicationProperties;
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java
index f8b39b6e36..5da71ee96e 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java
@@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.access.model.ResourceType;
import stirling.software.proprietary.access.service.OwnershipService;
import stirling.software.proprietary.integration.model.IntegrationConfig;
@@ -36,6 +37,7 @@ import tools.jackson.databind.ObjectMapper;
*/
@Slf4j
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class S3ConnectionResolver {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3IntegrationValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3IntegrationValidator.java
index 026c3f2377..f1b2c7382a 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3IntegrationValidator.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3IntegrationValidator.java
@@ -7,6 +7,7 @@ import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.cluster.s3.S3Clients;
import stirling.software.proprietary.integration.model.IntegrationType;
@@ -19,6 +20,7 @@ import stirling.software.proprietary.integration.service.IntegrationConfigValida
* time so a bad connection fails in the form rather than in a sweep.
*/
@Component
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class S3IntegrationValidator implements IntegrationConfigValidator {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java
index 9b347366bc..48f982ceb6 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java
@@ -13,6 +13,7 @@ import org.springframework.transaction.event.TransactionalEventListener;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.model.TeamCreatedEvent;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
@@ -27,6 +28,7 @@ import stirling.software.proprietary.security.service.TeamService;
*/
@Slf4j
@Component
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class DefaultClassificationPolicySeeder {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java
index 11a82187c9..562edc3025 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java
@@ -14,6 +14,8 @@ import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
+import stirling.software.common.annotations.ConditionalOnProcessor;
+
/**
* Durable {@link SourceDocCounter}; the runtime bean. {@code record} keeps two things in step: an
* hourly bucket ({@link SourceDocCountEntity}) that feeds the rolling 24h / 30d / daily-series
@@ -23,6 +25,7 @@ import org.springframework.stereotype.Service;
* table stays bounded (~one row per source per active hour, for at most 30 days).
*/
@Service
+@ConditionalOnProcessor
public class JpaSourceDocCounter implements SourceDocCounter {
private final SourceDocCountRepository countRepository;
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java
index 72ff59e914..c8bc671e01 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java
@@ -7,6 +7,7 @@ import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
@@ -18,6 +19,7 @@ import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
* {@link stirling.software.proprietary.policy.config.PolicyAccessGuard}.
*/
@Component
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class SourceAccessGuard {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java
index 73622738f0..a12e7114e5 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java
@@ -24,6 +24,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.config.FolderAccessDeniedException;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
@@ -42,6 +43,7 @@ import stirling.software.proprietary.util.SecretMasker;
* everything is scoped to the caller's team.
*/
@RestController
+@ConditionalOnProcessor
@RequestMapping("/api/v1/sources")
@Hidden
@RequiredArgsConstructor
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java
index 0f9df21440..93c93af46d 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.PolicyStore;
@@ -24,6 +25,7 @@ import stirling.software.proprietary.util.SecretMasker;
* always consistent with the live policy set.
*/
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class SourceOverviewService {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java
index 5b859a4786..f3821ec901 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java
@@ -25,6 +25,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.engine.PolicyRunner;
@@ -50,6 +51,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
*/
@Slf4j
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class FolderWatchTrigger implements PolicyTrigger {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java
index cf63853be9..fcdc004fa8 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java
@@ -8,9 +8,12 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
+
/** Starts and stops every {@link PolicyTrigger} with the application lifecycle. */
@Slf4j
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class PolicyTriggerManager implements SmartLifecycle {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java
index f4ffb30146..8bfe403b4b 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java
@@ -15,6 +15,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.engine.SweepKind;
@@ -34,6 +35,7 @@ import tools.jackson.databind.ObjectMapper;
*/
@Slf4j
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class ScheduleTrigger implements PolicyTrigger {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java
index d5bb412a7c..49bcc3f8b4 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.engine.SweepKind;
@@ -23,6 +24,7 @@ import stirling.software.proprietary.policy.webhook.WebhookConfig;
@Slf4j
@Service
+@ConditionalOnProcessor
@RequiredArgsConstructor
public class WebhookTrigger implements PolicyTrigger {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookReceiverController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookReceiverController.java
index 509c6c7fd3..8f2f2dff07 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookReceiverController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookReceiverController.java
@@ -23,6 +23,7 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
@@ -30,6 +31,7 @@ import stirling.software.proprietary.policy.trigger.WebhookTrigger;
@Slf4j
@RestController
+@ConditionalOnProcessor
@RequestMapping("/api/v1/webhooks")
@Hidden
@RequiredArgsConstructor
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookSpool.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookSpool.java
index 6a8377c67a..8ee6e9c276 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookSpool.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookSpool.java
@@ -8,9 +8,11 @@ import java.util.UUID;
import org.springframework.stereotype.Component;
+import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.configuration.InstallationPathConfig;
@Component
+@ConditionalOnProcessor
public class WebhookSpool {
private static final String SPOOL_DIR = "policy-webhook-spool";
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
new file mode 100644
index 0000000000..8aed069817
--- /dev/null
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ProcessorConditionalTest.java
@@ -0,0 +1,201 @@
+package stirling.software.proprietary.policy;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.security.CodeSource;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition;
+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.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;
+
+/**
+ * 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.
+ *
+ * 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
+ * the flag goes in {@link #DELIBERATELY_UNGATED} with the reason it is there.
+ */
+class ProcessorConditionalTest {
+
+ private static final List PROCESSOR_PACKAGES =
+ List.of(
+ "stirling.software.proprietary.policy",
+ "stirling.software.proprietary.integration");
+
+ /**
+ * Components that stay on with the Processor off, and why. Gating any of these breaks a
+ * non-Processor feature.
+ */
+ private static final Map DELIBERATELY_UNGATED =
+ Map.of(
+ "CredentialEncryption",
+ "KeyPersistenceService carries @DependsOn(\"credentialEncryption\");"
+ + " gating it kills JWT auth",
+ "AdminPolicyManagementAuthority",
+ "PolicyManagementAuthority backs the notification bell, which is not"
+ + " Processor-only",
+ "PolicyExecutor", "AiWorkflowService runs tool chains through it",
+ "JpaPolicyStore",
+ "JPA-backed store; gating buys nothing and can orphan readers",
+ "JpaSourceStore",
+ "JPA-backed store; gating buys nothing and can orphan readers");
+
+ @Test
+ void everyProcessorComponentIsGated() {
+ Set ungated = new TreeSet<>();
+ for (Class> type : mainProcessorComponents()) {
+ if (DELIBERATELY_UNGATED.containsKey(type.getSimpleName())) continue;
+ if (type.getAnnotation(ConditionalOnProcessor.class) == null) {
+ ungated.add(type.getName());
+ }
+ }
+ assertTrue(
+ ungated.isEmpty(),
+ "Processor components missing @ConditionalOnProcessor (add the annotation, or"
+ + " list it in DELIBERATELY_UNGATED with a reason): "
+ + ungated);
+ }
+
+ @Test
+ void theGateReadsProcessorEnabled() {
+ // 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");
+ }
+
+ @Test
+ void stacksWithAnotherConditionalOnProperty() {
+ // 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");
+ }
+
+ /** By type, not name: a nested @Configuration gets an outer-class-qualified bean name. */
+ private static boolean hasBean(String... properties) {
+ boolean[] present = {false};
+ new ApplicationContextRunner()
+ .withUserConfiguration(DoublyGated.class)
+ .withPropertyValues(properties)
+ .run(ctx -> present[0] = !ctx.getBeansOfType(DoublyGated.class).isEmpty());
+ return present[0];
+ }
+
+ @Configuration(proxyBeanMethods = false)
+ @ConditionalOnProperty(name = "feature.enabled", havingValue = "true")
+ @ConditionalOnProcessor
+ static class DoublyGated {}
+
+ @Test
+ void ungatedAllowanceStaysHonest() {
+ // A stale allow-list would silently excuse a class that no longer exists, so every
+ // entry must still be a real, still-ungated Processor component.
+ Set scanned = new LinkedHashSet<>();
+ for (Class> type : mainProcessorComponents()) {
+ scanned.add(type.getSimpleName());
+ }
+ for (String name : DELIBERATELY_UNGATED.keySet()) {
+ assertTrue(
+ scanned.contains(name),
+ name + " is allow-listed as ungated but is no longer a scanned component");
+ }
+ }
+
+ @Test
+ void repositoriesAreNotGated() {
+ // Gating a repository can leave a non-Processor consumer without a required bean
+ // (UserService and TeamController both inject IntegrationConfigRepository).
+ // Repositories are interfaces, which the stock scanner drops as non-instantiable.
+ ClassPathScanningCandidateComponentProvider scanner =
+ new ClassPathScanningCandidateComponentProvider(
+ false, environmentWithProcessorOn()) {
+ @Override
+ protected boolean isCandidateComponent(AnnotatedBeanDefinition definition) {
+ return definition.getMetadata().isIndependent();
+ }
+ };
+ scanner.addIncludeFilter(new AssignableTypeFilter(Repository.class));
+ int found = 0;
+ for (String pkg : PROCESSOR_PACKAGES) {
+ for (BeanDefinition bean : scanner.findCandidateComponents(pkg)) {
+ Class> type = loadClass(bean.getBeanClassName());
+ if (isTestClass(type)) continue;
+ found++;
+ assertTrue(
+ type.getAnnotation(ConditionalOnProcessor.class) == null,
+ type.getName() + " is a repository and must not be gated");
+ }
+ }
+ assertTrue(found >= 8, "scan found only " + found + " repositories - is it wired?");
+ }
+
+ /** Main-source components only - the test classpath also holds @SpringBootApplication stubs. */
+ private static Set> mainProcessorComponents() {
+ ClassPathScanningCandidateComponentProvider scanner =
+ new ClassPathScanningCandidateComponentProvider(true, environmentWithProcessorOn());
+ Set> all = new LinkedHashSet<>();
+ for (String pkg : PROCESSOR_PACKAGES) {
+ for (BeanDefinition bean : scanner.findCandidateComponents(pkg)) {
+ Class> type = loadClass(bean.getBeanClassName());
+ if (!isTestClass(type)) all.add(type);
+ }
+ }
+ assertTrue(all.size() > 40, "scan found only " + all.size() + " components - is it wired?");
+ return all;
+ }
+
+ /** Test fixtures live under build/classes/java/test; main code does not. */
+ private static boolean isTestClass(Class> type) {
+ CodeSource source = type.getProtectionDomain().getCodeSource();
+ if (source == null || source.getLocation() == null) return false;
+ return source.getLocation().getPath().replace('\\', '/').contains("/classes/java/test");
+ }
+
+ /** processor.enabled=true, else the scanner's condition evaluator hides the gated classes. */
+ private static StandardEnvironment environmentWithProcessorOn() {
+ StandardEnvironment environment = new StandardEnvironment();
+ environment
+ .getPropertySources()
+ .addFirst(new MapPropertySource("test", Map.of("processor.enabled", "true")));
+ return environment;
+ }
+
+ private static Class> loadClass(String name) {
+ try {
+ return Class.forName(name);
+ } catch (ClassNotFoundException e) {
+ throw new AssertionError("scanned class is not loadable: " + name, e);
+ }
+ }
+}
diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile
index cac30d88b1..2b0bc1f516 100644
--- a/docker/embedded/Dockerfile
+++ b/docker/embedded/Dockerfile
@@ -47,6 +47,9 @@ 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.
+ARG FRONTEND_MODE=
# Bundle only the JPDFium native for this image's target arch.
ARG TARGETARCH
@@ -55,6 +58,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
gradle clean build \
-PbuildWithFrontend=true \
-PbuildWithPortal=${BUILD_PORTAL} \
+ ${FRONTEND_MODE:+-PfrontendMode=${FRONTEND_MODE}} \
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
-PprototypesMode=${PROTOTYPES_BUILD} \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
diff --git a/frontend/editor/src/core/api/config.ts b/frontend/editor/src/core/api/config.ts
index 94caba2c82..b65d46c3c1 100644
--- a/frontend/editor/src/core/api/config.ts
+++ b/frontend/editor/src/core/api/config.ts
@@ -1,4 +1,5 @@
import apiClient from "@app/services/apiClient";
+import { setProcessorEnabled } from "@app/services/processorEnabled";
import { getSimulatedAppConfig } from "@app/testing/serverExperienceSimulations";
import type { AppConfig } from "@app/types/appConfig";
import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability";
@@ -6,20 +7,26 @@ import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailabilit
/** Unauthenticated and unreachable both mean "assume login is on". */
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);
+ return config;
+}
+
export async function fetchAppConfig(): Promise {
const simulated = getSimulatedAppConfig();
- if (simulated) return simulated;
+ if (simulated) return publishConfig(simulated);
try {
const response = await apiClient.get(
"/api/v1/config/app-config",
{ suppressErrorToast: true, skipAuthRedirect: true },
);
- return response.data;
+ return publishConfig(response.data);
} catch (error) {
// 401 is an answer, not a failure: the app runs unauthenticated.
if ((error as { response?: { status?: number } })?.response?.status === 401)
- return DEFAULT_APP_CONFIG;
+ return publishConfig(DEFAULT_APP_CONFIG);
throw error;
}
}
diff --git a/frontend/editor/src/core/components/onboarding/orchestrator/onboardingConfig.ts b/frontend/editor/src/core/components/onboarding/orchestrator/onboardingConfig.ts
index 9b8ba965c8..f765e039f5 100644
--- a/frontend/editor/src/core/components/onboarding/orchestrator/onboardingConfig.ts
+++ b/frontend/editor/src/core/components/onboarding/orchestrator/onboardingConfig.ts
@@ -37,6 +37,8 @@ export interface OnboardingRuntimeState {
export interface OnboardingConditionContext extends OnboardingRuntimeState {
loginEnabled: boolean;
effectiveIsAdmin: boolean;
+ /** Server runs the Processor. False = editor-only deployment. */
+ processorEnabled: boolean;
}
export interface OnboardingStep {
@@ -96,7 +98,7 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
type: "modal-slide",
slideId: "processor-intro",
// Admins can manage policies in the portal/processor; regular users can't.
- condition: (ctx) => ctx.effectiveIsAdmin,
+ condition: (ctx) => ctx.effectiveIsAdmin && ctx.processorEnabled,
},
{
id: "admin-overview",
diff --git a/frontend/editor/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts b/frontend/editor/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts
index d0ec8a66b5..49a95d6dd8 100644
--- a/frontend/editor/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts
+++ b/frontend/editor/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts
@@ -2,6 +2,7 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { useServerExperience } from "@app/hooks/useServerExperience";
import { useAppConfig } from "@app/contexts/AppConfigContext";
+import { useProcessorEnabled } from "@app/hooks/useProcessorEnabled";
import {
ONBOARDING_STEPS,
@@ -172,6 +173,7 @@ export function useOnboardingOrchestrator(
const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE;
const serverExperience = useServerExperience();
const { config, loading: configLoading } = useAppConfig();
+ const processorEnabled = useProcessorEnabled();
const location = useLocation();
const bypassOnboarding = useBypassOnboarding();
@@ -265,12 +267,13 @@ export function useOnboardingOrchestrator(
() => ({
...serverExperience,
...runtimeState,
+ processorEnabled,
effectiveIsAdmin:
serverExperience.effectiveIsAdmin ||
(!serverExperience.loginEnabled &&
runtimeState.selectedRole === "admin"),
}),
- [serverExperience, runtimeState],
+ [serverExperience, runtimeState, processorEnabled],
);
const activeFlow = useMemo(() => {
diff --git a/frontend/editor/src/core/components/shared/config/configSections/providerDefinitions.ts b/frontend/editor/src/core/components/shared/config/configSections/providerDefinitions.ts
index 72dc675f8f..9a6792df58 100644
--- a/frontend/editor/src/core/components/shared/config/configSections/providerDefinitions.ts
+++ b/frontend/editor/src/core/components/shared/config/configSections/providerDefinitions.ts
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { oauthIconUrl } from "@app/auth/ui/oauthIcons";
+import { useProcessorEnabled } from "@app/hooks/useProcessorEnabled";
export type ProviderType = "oauth2" | "saml2" | "telegram" | "googledrive";
@@ -868,7 +869,10 @@ export const useAllProviders = (): Provider[] => {
const telegramProvider = useTelegramProvider();
const saml2Provider = useSAML2Provider();
const googleDriveProvider = useGoogleDriveProvider();
+ const processorEnabled = useProcessorEnabled();
+ // The bot drops files in the watched folder and polls the finished one, so
+ // without the Processor every upload just times out - don't offer to set it up.
return [
googleProvider,
gitHubProvider,
@@ -876,7 +880,7 @@ export const useAllProviders = (): Provider[] => {
genericOAuth2Provider,
saml2Provider,
smtpProvider,
- telegramProvider,
+ ...(processorEnabled ? [telegramProvider] : []),
googleDriveProvider,
];
};
diff --git a/frontend/editor/src/core/contexts/AppConfigContext.tsx b/frontend/editor/src/core/contexts/AppConfigContext.tsx
index 112ca92bb8..65fef0f5af 100644
--- a/frontend/editor/src/core/contexts/AppConfigContext.tsx
+++ b/frontend/editor/src/core/contexts/AppConfigContext.tsx
@@ -9,6 +9,7 @@ import React, {
} from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { DEFAULT_APP_CONFIG, fetchAppConfig } from "@app/api/config";
+import { setProcessorEnabled } from "@app/services/processorEnabled";
import { qk } from "@app/query/keys";
import { CONFIG_STALE_TIME } from "@app/query/staleTime";
import type { AppConfig, AppConfigBootstrapMode } from "@app/types/appConfig";
@@ -115,6 +116,13 @@ export const AppConfigProvider: React.FC = ({
if (data) onConfigLoadedRef.current?.(data);
}, [data]);
+ // fetchAppConfig publishes this too; repeat it for the seeded path, which
+ // never fetches.
+ useEffect(() => {
+ if (!data && initialConfig)
+ setProcessorEnabled(initialConfig.processorEnabled === true);
+ }, [data, initialConfig]);
+
const value = useMemo(
() => ({
config:
diff --git a/frontend/editor/src/core/data/classifyIsAPipelineTask.test.tsx b/frontend/editor/src/core/data/classifyIsAPipelineTask.test.tsx
index f5281f80ae..018d1527ba 100644
--- a/frontend/editor/src/core/data/classifyIsAPipelineTask.test.tsx
+++ b/frontend/editor/src/core/data/classifyIsAPipelineTask.test.tsx
@@ -7,7 +7,7 @@
* reached the spec at all. These tests pin each link, because any one of them silently removes the
* step from the builder's picker rather than failing loudly.
*/
-import { describe, expect, test, vi } from "vitest";
+import { afterEach, describe, expect, test, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
import { getExecutableTools } from "@app/hooks/tools/shared/toolAutomation";
@@ -15,6 +15,14 @@ import { isToolEndpoint } from "@app/hooks/tools/shared/toolApiMapping";
import { TOOL_IO } from "@app/types/toolIO";
import { filterToolRegistryByQuery } from "@app/utils/toolSearch";
+// The registry drops classify on an editor-only server, and the real hook reads app-config,
+// which this suite never provides - so state the flag rather than inheriting its default.
+const flags = vi.hoisted(() => ({ processorEnabled: true }));
+vi.mock("@app/hooks/useProcessorEnabled", () => ({
+ useProcessorEnabled: () => flags.processorEnabled,
+ isProcessorEnabled: () => flags.processorEnabled,
+}));
+
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
@@ -26,6 +34,10 @@ vi.mock("react-i18next", () => ({
const CLASSIFY_ENDPOINT = "/api/v1/ai/tools/classify-and-label";
describe("classify as a pipeline task", () => {
+ afterEach(() => {
+ flags.processorEnabled = true;
+ });
+
test("the classify endpoint is a generated ToolEndpoint", () => {
// Fails if the controller goes back to @Hidden, or the generator's allowlist drops the
// /api/v1/ai/tools/ namespace, or nobody regenerated after either.
@@ -73,4 +85,18 @@ describe("classify as a pipeline task", () => {
// would be a second engine call, and a second charge, for the same answer.
expect(config?.defaultParameters).toEqual({ reclassify: false });
});
+
+ test("an editor-only server does not offer it at all", () => {
+ // processor.enabled=false gates ClassifyLabelController, so the one endpoint behind this
+ // step stops being mapped. Leaving the step listed would offer a pipeline it cannot run.
+ flags.processorEnabled = false;
+ const { result } = renderHook(() => useTranslatedToolCatalog());
+
+ expect(result.current.allTools.classify).toBeUndefined();
+ expect(
+ getExecutableTools(result.current.regularTools).some(
+ (tool) => tool.toolId === "classify",
+ ),
+ ).toBe(false);
+ });
});
diff --git a/frontend/editor/src/core/data/settingsContentSearch.ts b/frontend/editor/src/core/data/settingsContentSearch.ts
index 7cee4d6056..8d3929f4e8 100644
--- a/frontend/editor/src/core/data/settingsContentSearch.ts
+++ b/frontend/editor/src/core/data/settingsContentSearch.ts
@@ -1,5 +1,6 @@
import i18n from "i18next";
import type { TFunction } from "i18next";
+import { isProcessorEnabled } from "@app/services/processorEnabled";
/**
* Content-level settings search: matches a query against every translation
@@ -78,6 +79,43 @@ export const getTranslationPrefixesForNavKey = (key: string): string[] => {
return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes]));
};
+/**
+ * Sub-keys, relative to a section's translation prefix, whose controls only
+ * render when the server runs the Processor. Matching whole subtrees means a
+ * hidden control's copy is still searchable, so drop these when it is off.
+ */
+const PROCESSOR_ONLY_SUBTREES: Record = {
+ "settings.general": ["loginLanding"],
+ // The group blurb names pipeline processing too; the External Tool Paths
+ // labels still make this section findable without it.
+ "admin.settings.general": ["customPaths.pipeline", "customPaths.description"],
+ // Sibling of the two nav labels the Connections section really uses.
+ "settings.securityAuth": ["telegram"],
+};
+
+/**
+ * Whole prefixes to skip with the Processor off. The Telegram bot round-trips
+ * through the pipeline folders, so its provider card is dropped from
+ * Connections entirely, not just trimmed.
+ */
+const PROCESSOR_ONLY_PREFIXES = new Set(["admin.settings.telegram"]);
+
+/** Returns the subtree with the given dotted paths removed (non-mutating). */
+const omitPaths = (value: unknown, paths: string[]): unknown => {
+ if (!value || typeof value !== "object") return value;
+ const copy: Record = {
+ ...(value as Record),
+ };
+ for (const path of paths) {
+ const [head, ...rest] = path.split(".");
+ if (!(head in copy)) continue;
+ copy[head] = rest.length
+ ? omitPaths(copy[head], [rest.join(".")])
+ : undefined;
+ }
+ return copy;
+};
+
export const flattenTranslationStrings = (value: unknown): string[] => {
if (typeof value === "string") {
const trimmed = value.trim();
@@ -143,15 +181,22 @@ export function getSettingsSectionContent(key: string, t: TFunction): string[] {
contentCache.clear();
contentCacheLanguage = i18n.language;
}
- const cached = contentCache.get(key);
+ // Part of the cache key: app-config can land after a first query cached the
+ // Processor-on content, and that entry must not outlive the answer.
+ const processorOn = isProcessorEnabled();
+ const cacheKey = `${key}:${processorOn}`;
+ const cached = contentCache.get(cacheKey);
if (cached) return cached;
- const content = getTranslationPrefixesForNavKey(key).flatMap((prefix) =>
- flattenTranslationStrings(
- t(prefix, { returnObjects: true, defaultValue: {} }),
- ),
- );
- contentCache.set(key, content);
+ const content = getTranslationPrefixesForNavKey(key).flatMap((prefix) => {
+ if (!processorOn && PROCESSOR_ONLY_PREFIXES.has(prefix)) return [];
+ const subtree = t(prefix, { returnObjects: true, defaultValue: {} });
+ const omitted = PROCESSOR_ONLY_SUBTREES[prefix];
+ return flattenTranslationStrings(
+ !processorOn && omitted ? omitPaths(subtree, omitted) : subtree,
+ );
+ });
+ contentCache.set(cacheKey, content);
return content;
}
diff --git a/frontend/editor/src/core/data/settingsSectionRegistry.ts b/frontend/editor/src/core/data/settingsSectionRegistry.ts
index e2222d4daf..0b1d1678b7 100644
--- a/frontend/editor/src/core/data/settingsSectionRegistry.ts
+++ b/frontend/editor/src/core/data/settingsSectionRegistry.ts
@@ -49,6 +49,8 @@ export interface SettingsSectionEntry {
* which keys off the local backend's login mode rather than auth state.
*/
requiresAccount?: boolean;
+ /** Section only exists when the server runs the Processor (processor.enabled). */
+ processorOnly?: boolean;
}
/** Core (OSS) sections — always present in every build. */
diff --git a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx
index ab4406abfd..8aff5e5906 100644
--- a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx
+++ b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx
@@ -65,6 +65,7 @@ import { extractPagesOperationConfig } from "@app/hooks/tools/extractPages/useEx
import { ENDPOINTS as SPLIT_ENDPOINT_NAMES } from "@app/constants/splitConstants";
import { ToolId } from "@app/types/toolId";
import { CONVERT_SUPPORTED_FORMATS } from "@app/constants/convertSupportedFornats";
+import { useProcessorEnabled } from "@app/hooks/useProcessorEnabled";
export interface TranslatedToolCatalog {
allTools: ToolRegistry;
@@ -78,6 +79,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
const { t } = useTranslation();
const proprietaryTools = useProprietaryToolRegistry();
const prototypeTools = usePrototypeToolRegistry();
+ const processorEnabled = useProcessorEnabled();
return useMemo(() => {
const allTools: ToolRegistry = {
@@ -1489,6 +1491,15 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
},
};
+ // Folder scanning is driven by PipelineDirectoryProcessor, and classify's only
+ // endpoint is gated with the Processor, so neither can work on an editor-only
+ // server. Reflect, not delete: ToolRegistry declares every id, and callers
+ // already optional-chain registry lookups.
+ if (!processorEnabled) {
+ Reflect.deleteProperty(allTools, "devFolderScanning");
+ Reflect.deleteProperty(allTools, "classify");
+ }
+
const regularTools = {} as RegularToolRegistry;
const superTools = {} as SuperToolRegistry;
const linkTools = {} as LinkToolRegistry;
@@ -1510,5 +1521,5 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
superTools,
linkTools,
};
- }, [t, proprietaryTools, prototypeTools]); // Re-compute when translations, proprietary, or prototype tools change
+ }, [t, proprietaryTools, prototypeTools, processorEnabled]); // Re-compute when translations, proprietary, or prototype tools change
}
diff --git a/frontend/editor/src/core/hooks/useProcessorEnabled.ts b/frontend/editor/src/core/hooks/useProcessorEnabled.ts
new file mode 100644
index 0000000000..9fb1c68c77
--- /dev/null
+++ b/frontend/editor/src/core/hooks/useProcessorEnabled.ts
@@ -0,0 +1,15 @@
+import { useAppConfig } from "@app/contexts/AppConfigContext";
+
+export { isProcessorEnabled } from "@app/services/processorEnabled";
+
+/**
+ * Whether this server runs the Processor (policies, sources, classification).
+ * Mirrors the backend's `processor.enabled`, which gates the same features there.
+ *
+ * Defaults to OFF until app-config resolves so an editor-only deployment never
+ * flashes Processor UI or fires a request at an endpoint that isn't mapped.
+ */
+export function useProcessorEnabled(): boolean {
+ const { config } = useAppConfig();
+ return config?.processorEnabled ?? false;
+}
diff --git a/frontend/editor/src/core/hooks/useSuperSearch.test.ts b/frontend/editor/src/core/hooks/useSuperSearch.test.ts
index 0f1f573e8b..47eaa24f1b 100644
--- a/frontend/editor/src/core/hooks/useSuperSearch.test.ts
+++ b/frontend/editor/src/core/hooks/useSuperSearch.test.ts
@@ -257,6 +257,7 @@ describe("useSuperSearch helpers", () => {
{
isAdmin: false,
loginEnabled: false,
+ processorEnabled: true,
},
selectEntry,
);
@@ -277,10 +278,27 @@ describe("useSuperSearch helpers", () => {
isAdmin: false,
loginEnabled: true,
portalAccessible: true,
+ processorEnabled: true,
},
vi.fn(),
);
expect(results.map((result) => result.key)).toEqual(["processor:users"]);
});
+
+ it("closes the Processor group on an editor-only server", () => {
+ const results = rankProcessorResults(
+ "members",
+ t,
+ {
+ isAdmin: true,
+ loginEnabled: true,
+ portalAccessible: true,
+ processorEnabled: false,
+ },
+ vi.fn(),
+ );
+
+ expect(results).toEqual([]);
+ });
});
diff --git a/frontend/editor/src/core/hooks/useSuperSearch.ts b/frontend/editor/src/core/hooks/useSuperSearch.ts
index 9f6a16f2f4..91231238f8 100644
--- a/frontend/editor/src/core/hooks/useSuperSearch.ts
+++ b/frontend/editor/src/core/hooks/useSuperSearch.ts
@@ -82,13 +82,14 @@ const GROUP_ORDER: SuperSearchGroupId[] = [
];
/**
- * Whether the current user can enter the Processor at all: explicit portal
- * access, admin, or single-user mode with login disabled. Null gates (config
- * still loading) stay closed.
+ * Whether the current user can enter the Processor at all: the server runs one,
+ * plus explicit portal access, admin, or single-user mode with login disabled.
+ * Null gates (config still loading) stay closed.
*/
export function isProcessorGateOpen(gates: SuperSearchGates | null): boolean {
return (
!!gates &&
+ gates.processorEnabled === true &&
(gates.portalAccessible === true || gates.isAdmin || !gates.loginEnabled)
);
}
@@ -104,6 +105,7 @@ export function useSuperSearchGates(): SuperSearchGates | null {
isAdmin: authState.isAdmin ?? config.isAdmin ?? false,
loginEnabled: config.enableLogin ?? false,
portalAccessible: authState.portalAccess ?? false,
+ processorEnabled: config.processorEnabled ?? false,
isAnonymous: authState.isAnonymous,
showSettingsWhenNoLogin: config.showSettingsWhenNoLogin ?? true,
}
@@ -327,6 +329,9 @@ export function rankSettingsResults(
// Account-bound sections mirror the SaaS builder's `!isAnonymous` gate.
if (s.requiresAccount && (gates ? (gates.isAnonymous ?? false) : true))
return false;
+ // Editor-only server: the nav builder drops these, so offering them here
+ // would deep-link into a section the modal no longer renders.
+ if (s.processorOnly && gates?.processorEnabled !== true) return false;
return true;
});
// Row context: the display label of the section the row lives in.
diff --git a/frontend/editor/src/core/services/processorEnabled.ts b/frontend/editor/src/core/services/processorEnabled.ts
new file mode 100644
index 0000000000..7754716972
--- /dev/null
+++ b/frontend/editor/src/core/services/processorEnabled.ts
@@ -0,0 +1,20 @@
+/**
+ * Non-hook mirror of the server's `processor.enabled`, written when app-config
+ * resolves. For code that can't use `useProcessorEnabled`: plain services and
+ * anything mounted above the providers.
+ */
+
+let enabled: boolean | null = null;
+
+export function setProcessorEnabled(value: boolean): void {
+ enabled = value;
+}
+
+/**
+ * Unknown counts as enabled: unlike the hook this never re-runs when config
+ * lands, so failing closed would permanently disable callers that resolve
+ * before the first fetch.
+ */
+export function isProcessorEnabled(): boolean {
+ return enabled !== false;
+}
diff --git a/frontend/editor/src/core/tests/helpers/api-stubs.ts b/frontend/editor/src/core/tests/helpers/api-stubs.ts
index fc476af535..6ce4473e47 100644
--- a/frontend/editor/src/core/tests/helpers/api-stubs.ts
+++ b/frontend/editor/src/core/tests/helpers/api-stubs.ts
@@ -139,6 +139,8 @@ export interface MockAppApiOptions {
endpointsAvailability?: Record;
/** Backend probe status. Set to `"DOWN"` to exercise offline-mode UI. */
backendStatus?: "UP" | "DOWN";
+ /** Server's `processor.enabled`. Set `false` for an editor-only deployment. */
+ processorEnabled?: boolean;
}
/**
@@ -162,6 +164,7 @@ export async function mockAppApis(
defaultLocale = "en-US",
endpointsAvailability = {},
backendStatus = "UP",
+ processorEnabled = true,
} = opts;
// Backend liveness probe — determines whether the UI shows the app or an offline screen
@@ -177,6 +180,7 @@ export async function mockAppApis(
isAdmin,
languages,
defaultLocale,
+ processorEnabled,
},
}),
);
diff --git a/frontend/editor/src/core/tests/stubbed/processor-disabled.spec.ts b/frontend/editor/src/core/tests/stubbed/processor-disabled.spec.ts
new file mode 100644
index 0000000000..c0ee9a9327
--- /dev/null
+++ b/frontend/editor/src/core/tests/stubbed/processor-disabled.spec.ts
@@ -0,0 +1,238 @@
+import { test, expect } from "@app/tests/helpers/stub-test-base";
+import type { ConsoleMessage, Page } from "@playwright/test";
+
+/**
+ * `processor.enabled=false` (an editor-only server): the editor must show no
+ * Processor UI at all, and must not ask the server for Processor data.
+ *
+ * Every absence assertion is paired with a positive control below, using the
+ * same admin-with-portal-access user and differing only in the flag - without
+ * that pairing an empty page would pass this file trivially.
+ *
+ * As in super-search.spec.ts, lane assertions that need the portal bundled
+ * skip themselves on builds that ship none (VITE_INCLUDE_PORTAL); closed lanes
+ * and absent lanes look identical, so only the gate-open controls are affected.
+ */
+
+const INPUT = "#super-search-input";
+
+// The Processor's own data endpoints - the same URL surface ProcessorEndpointSurfaceTest
+// keeps unmapped on the server. None may be requested with the flag off. classify-and-label
+// is spelled out because /api/v1/ai/tools also holds editor-only AI agents.
+const PROCESSOR_API =
+ /^\/api\/v1\/(policies|sources|integration|webhooks|pipeline|ai\/tools\/classify-and-label)/;
+
+const ADMIN_WITH_PORTAL = {
+ id: 1,
+ username: "admin",
+ email: "admin@example.com",
+ role: "ROLE_ADMIN",
+ portalAccess: true,
+};
+
+const SWITCH_APP = "Switch app";
+const OPEN_PROCESSOR = "Open PDF Processor";
+
+// Copy that only a Processor server can honour: a link tool pointing at the
+// folder-scanning guide, and settings controls hidden with the flag off. The
+// Telegram bot round-trips through the pipeline folders, so it goes too.
+const FOLDER_SCANNING = "Automated Folder Scanning";
+const WATCHED_FOLDERS = "Watched Folders Directory";
+const AFTER_SIGNING_IN = "After signing in";
+const TELEGRAM = "Telegram";
+
+const PROCESSOR_ONLY_COPY = [
+ FOLDER_SCANNING,
+ WATCHED_FOLDERS,
+ AFTER_SIGNING_IN,
+ TELEGRAM,
+];
+
+async function openSearch(page: Page) {
+ const input = page.locator(INPUT);
+ await input.click();
+ await expect(input).toHaveAttribute("aria-expanded", "true");
+ return input;
+}
+
+/** The portal lanes only exist in a build that bundles the portal. */
+async function requirePortalBuild(page: Page) {
+ const ships =
+ (await page.getByRole("button", { name: "Pages", exact: true }).count()) >
+ 0;
+ test.skip(!ships, "this build ships no portal - no lanes to gate");
+}
+
+function recordProcessorRequests(page: Page): string[] {
+ const seen: string[] = [];
+ page.on("request", (request) => {
+ const { pathname } = new URL(request.url());
+ if (PROCESSOR_API.test(pathname)) seen.push(pathname);
+ });
+ return seen;
+}
+
+// Pre-existing dev-server noise, unrelated to this flag: i18n runs with
+// useSuspense, so a cold namespace load warns on every route (console-clean.spec
+// fails identically on /merge and /compress against the dev server).
+const KNOWN_NOISE = [
+ /react-i18next::i18next: useTranslation: suspended/,
+ /i18next::backendConnector: loading namespace/,
+];
+
+function recordConsole(page: Page): string[] {
+ const problems: string[] = [];
+ const keep = (text: string) => {
+ if (!KNOWN_NOISE.some((re) => re.test(text))) problems.push(text);
+ };
+ page.on("console", (msg: ConsoleMessage) => {
+ const type = msg.type();
+ if (type === "error" || type === "warning") keep(msg.text());
+ });
+ page.on("pageerror", (err) => keep(err.stack ?? err.message));
+ return problems;
+}
+
+test.describe("editor-only server (processor.enabled=false)", () => {
+ test.use({
+ // Off, so the listeners below are attached before the only navigation -
+ // a second goto re-races i18n's namespace fetch and reports its own noise.
+ autoGoto: false,
+ seedJwt: true,
+ stubOptions: {
+ enableLogin: true,
+ processorEnabled: false,
+ user: ADMIN_WITH_PORTAL,
+ },
+ });
+
+ test("shows no Processor entry points and fetches no Processor data", async ({
+ page,
+ }) => {
+ const requests = recordProcessorRequests(page);
+ const problems = recordConsole(page);
+ await page.goto("/editor", { waitUntil: "domcontentloaded" });
+ await expect(page.locator(INPUT)).toBeVisible();
+
+ // The logo stays a plain logo: no editor -> processor switcher...
+ await expect(page.getByLabel(SWITCH_APP)).toHaveCount(0);
+ // ...and the sidebar footer offers no "Open PDF Processor" row.
+ await expect(page.getByLabel(OPEN_PROCESSOR)).toHaveCount(0);
+
+ // Nothing asked the server for Processor data on this user's behalf.
+ await page.waitForTimeout(1500);
+ expect(requests).toEqual([]);
+
+ // An editor-only server must not be a degraded one.
+ expect(problems).toEqual([]);
+ });
+
+ test("offers no Processor lanes or results in super search", async ({
+ page,
+ }) => {
+ await page.goto("/editor", { waitUntil: "domcontentloaded" });
+ const input = await openSearch(page);
+
+ // The editor's own lane is present (control), the Processor's are not.
+ await expect(
+ page.getByRole("button", { name: "Tools", exact: true }),
+ ).toBeVisible();
+ for (const lane of ["Pages", "Policies", "Sources", "Pipelines", "Users"]) {
+ await expect(
+ page.getByRole("button", { name: lane, exact: true }),
+ ).toHaveCount(0);
+ }
+
+ // A query that hits policies when the Processor is on yields no section.
+ await input.fill("security");
+ await expect(page.getByRole("option").first()).toBeVisible();
+ await expect(
+ page.locator(".super-search-section-label", { hasText: "Processor" }),
+ ).toHaveCount(0);
+ });
+
+ test("offers no folder-scanning tool and no pipeline settings copy", async ({
+ page,
+ }) => {
+ // Traces a code-only sweep missed: the tool card links a guide to a feature
+ // this server can't run, and settings search matches whole i18n subtrees,
+ // so hidden controls' copy stayed findable.
+ await page.goto("/editor", { waitUntil: "domcontentloaded" });
+ const input = await openSearch(page);
+
+ for (const term of PROCESSOR_ONLY_COPY) {
+ await input.fill(term);
+ // Settled: the list re-renders per keystroke, so assert after a paint.
+ await expect(page.locator(INPUT)).toHaveValue(term);
+ await expect(page.getByRole("option", { name: term })).toHaveCount(0);
+ }
+ });
+
+ test("bounces a hand-typed /processor URL back to the editor", async ({
+ page,
+ }) => {
+ // The last hole: hiding the entry points doesn't stop someone typing the URL.
+ // The editor shell rendering here is the assertion - the portal has its own
+ // shell and no #super-search-input, as the control below shows.
+ await page.goto("/processor", { waitUntil: "domcontentloaded" });
+ await expect(page.locator(INPUT)).toBeVisible();
+ });
+});
+
+test.describe("same user on a server with the Processor on", () => {
+ // Positive controls: the identical account, differing only in the flag.
+ test.use({
+ seedJwt: true,
+ stubOptions: {
+ enableLogin: true,
+ processorEnabled: true,
+ user: ADMIN_WITH_PORTAL,
+ },
+ });
+
+ test("is offered the switch to the Processor", async ({ page }) => {
+ await expect(page.locator(INPUT)).toBeVisible();
+ await expect(page.getByLabel(SWITCH_APP)).toBeVisible();
+ });
+
+ test("is offered Processor lanes in super search", async ({ page }) => {
+ await openSearch(page);
+ await requirePortalBuild(page);
+
+ for (const lane of ["Policies", "Sources", "Pipelines"]) {
+ await expect(
+ page.getByRole("button", { name: lane, exact: true }),
+ ).toBeVisible();
+ }
+ });
+
+ test("finds the folder-scanning tool and the pipeline settings copy", async ({
+ page,
+ }) => {
+ // Control for the absence test above: these terms are findable here, so
+ // their disappearance with the flag off is the gate, not a typo.
+ const input = await openSearch(page);
+
+ for (const term of PROCESSOR_ONLY_COPY) {
+ await input.fill(term);
+ await expect(
+ page.getByRole("option", { name: term }).first(),
+ ).toBeVisible();
+ }
+ });
+
+ test("stays on /processor and renders the portal", async ({ page }) => {
+ // Control for the bounce test: same URL, same account, flag on. The editor
+ // shell not rendering is what makes "it renders" above a real assertion.
+ await page.goto("/processor", { waitUntil: "domcontentloaded" });
+ const editorMounted = await page
+ .locator(INPUT)
+ .isVisible()
+ .catch(() => false);
+ test.skip(
+ editorMounted,
+ "this build ships no portal - nothing mounts here",
+ );
+ await expect(page).toHaveURL(/\/processor\b/);
+ });
+});
diff --git a/frontend/editor/src/core/types/appConfig.ts b/frontend/editor/src/core/types/appConfig.ts
index 2dafb3d07a..28a5b7e5f6 100644
--- a/frontend/editor/src/core/types/appConfig.ts
+++ b/frontend/editor/src/core/types/appConfig.ts
@@ -63,6 +63,8 @@ export interface AppConfig {
timestampCustomTsaUrls?: string[];
timestampTsaPresets?: { label: string; url: string }[];
aiEngineEnabled?: boolean;
+ /** Processor (policies, sources, classification). False = editor-only server. */
+ processorEnabled?: boolean;
}
export type AppConfigBootstrapMode = "blocking" | "non-blocking";
diff --git a/frontend/editor/src/core/types/superSearch.ts b/frontend/editor/src/core/types/superSearch.ts
index 3d8e72849f..6018ca69af 100644
--- a/frontend/editor/src/core/types/superSearch.ts
+++ b/frontend/editor/src/core/types/superSearch.ts
@@ -61,6 +61,8 @@ export interface SuperSearchGates {
isAdmin: boolean;
loginEnabled: boolean;
portalAccessible?: boolean;
+ /** Server runs the Processor at all. False = editor-only deployment. */
+ processorEnabled?: boolean;
/**
* Whether no-login mode keeps the read-only admin settings preview
* (`system.showSettingsWhenNoLogin`, default true). Mirrors the settings
diff --git a/frontend/editor/src/desktop/components/policies/usePoliciesEnabled.ts b/frontend/editor/src/desktop/components/policies/usePoliciesEnabled.ts
index e04859a45e..ac426c8e65 100644
--- a/frontend/editor/src/desktop/components/policies/usePoliciesEnabled.ts
+++ b/frontend/editor/src/desktop/components/policies/usePoliciesEnabled.ts
@@ -1,5 +1,8 @@
import { useConfirmedSaaSMode } from "@app/hooks/useConfirmedSaaSMode";
+import { useProcessorEnabled } from "@app/hooks/useProcessorEnabled";
export function usePoliciesEnabled(): boolean {
- return useConfirmedSaaSMode();
+ const saasMode = useConfirmedSaaSMode();
+ const processorEnabled = useProcessorEnabled();
+ return saasMode && processorEnabled;
}
diff --git a/frontend/editor/src/editoronly/components/policies/PolicyAutoRunController.tsx b/frontend/editor/src/editoronly/components/policies/PolicyAutoRunController.tsx
new file mode 100644
index 0000000000..eab81db6f3
--- /dev/null
+++ b/frontend/editor/src/editoronly/components/policies/PolicyAutoRunController.tsx
@@ -0,0 +1 @@
+export { PolicyAutoRunController } from "@core/components/policies/PolicyAutoRunController";
diff --git a/frontend/editor/src/editoronly/components/policies/usePoliciesEnabled.ts b/frontend/editor/src/editoronly/components/policies/usePoliciesEnabled.ts
new file mode 100644
index 0000000000..d25d8aef51
--- /dev/null
+++ b/frontend/editor/src/editoronly/components/policies/usePoliciesEnabled.ts
@@ -0,0 +1 @@
+export { usePoliciesEnabled } from "@core/components/policies/usePoliciesEnabled";
diff --git a/frontend/editor/src/editoronly/components/shared/config/GeneralWithLoginLanding.tsx b/frontend/editor/src/editoronly/components/shared/config/GeneralWithLoginLanding.tsx
new file mode 100644
index 0000000000..dee4525043
--- /dev/null
+++ b/frontend/editor/src/editoronly/components/shared/config/GeneralWithLoginLanding.tsx
@@ -0,0 +1,2 @@
+// No Processor -> no "after signing in" choice; render plain General settings.
+export { default } from "@core/components/shared/config/configSections/GeneralSection";
diff --git a/frontend/editor/src/editoronly/components/shared/config/configSections/AdminFolderAccessSection.tsx b/frontend/editor/src/editoronly/components/shared/config/configSections/AdminFolderAccessSection.tsx
new file mode 100644
index 0000000000..1c49fcf833
--- /dev/null
+++ b/frontend/editor/src/editoronly/components/shared/config/configSections/AdminFolderAccessSection.tsx
@@ -0,0 +1,4 @@
+// Folder sources/outputs are Processor-only; this build has none.
+export default function AdminFolderAccessSection() {
+ return null;
+}
diff --git a/frontend/editor/src/editoronly/components/viewer/Viewer.tsx b/frontend/editor/src/editoronly/components/viewer/Viewer.tsx
new file mode 100644
index 0000000000..b7ad8bbddc
--- /dev/null
+++ b/frontend/editor/src/editoronly/components/viewer/Viewer.tsx
@@ -0,0 +1,3 @@
+import CoreViewer from "@core/components/viewer/Viewer";
+export type { ViewerProps } from "@core/components/viewer/Viewer";
+export default CoreViewer;
diff --git a/frontend/editor/src/editoronly/data/labelDisplay.ts b/frontend/editor/src/editoronly/data/labelDisplay.ts
new file mode 100644
index 0000000000..807e6ee589
--- /dev/null
+++ b/frontend/editor/src/editoronly/data/labelDisplay.ts
@@ -0,0 +1 @@
+export { useLabelName } from "@core/data/labelDisplay";
diff --git a/frontend/editor/src/editoronly/data/processorEntitySearch.ts b/frontend/editor/src/editoronly/data/processorEntitySearch.ts
new file mode 100644
index 0000000000..0dba9eaf38
--- /dev/null
+++ b/frontend/editor/src/editoronly/data/processorEntitySearch.ts
@@ -0,0 +1 @@
+export { useProcessorEntityGroups } from "@core/data/processorEntitySearch";
diff --git a/frontend/editor/src/editoronly/data/processorSearchIndex.ts b/frontend/editor/src/editoronly/data/processorSearchIndex.ts
new file mode 100644
index 0000000000..a6e242eb15
--- /dev/null
+++ b/frontend/editor/src/editoronly/data/processorSearchIndex.ts
@@ -0,0 +1,5 @@
+export type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
+export {
+ PROCESSOR_SEARCH_INDEX,
+ isPortalEntityScopeAccessible,
+} from "@core/data/processorSearchIndex";
diff --git a/frontend/editor/src/editoronly/hooks/useClassificationEnabled.ts b/frontend/editor/src/editoronly/hooks/useClassificationEnabled.ts
new file mode 100644
index 0000000000..f0701d709e
--- /dev/null
+++ b/frontend/editor/src/editoronly/hooks/useClassificationEnabled.ts
@@ -0,0 +1 @@
+export { useClassificationEnabled } from "@core/hooks/useClassificationEnabled";
diff --git a/frontend/editor/src/editoronly/hooks/useOtherAppSwitch.ts b/frontend/editor/src/editoronly/hooks/useOtherAppSwitch.ts
new file mode 100644
index 0000000000..42f9f9ee19
--- /dev/null
+++ b/frontend/editor/src/editoronly/hooks/useOtherAppSwitch.ts
@@ -0,0 +1 @@
+export { useOtherAppSwitch } from "@core/hooks/useOtherAppSwitch";
diff --git a/frontend/editor/src/editoronly/hooks/usePolicyFileBadges.ts b/frontend/editor/src/editoronly/hooks/usePolicyFileBadges.ts
new file mode 100644
index 0000000000..6a96f1869d
--- /dev/null
+++ b/frontend/editor/src/editoronly/hooks/usePolicyFileBadges.ts
@@ -0,0 +1,5 @@
+export type { LineageStub } from "@core/hooks/usePolicyFileBadges";
+export {
+ usePolicyFileBadges,
+ usePolicyFileProcessing,
+} from "@core/hooks/usePolicyFileBadges";
diff --git a/frontend/editor/src/editoronly/hooks/useProcessorEnabled.ts b/frontend/editor/src/editoronly/hooks/useProcessorEnabled.ts
new file mode 100644
index 0000000000..057adf99b1
--- /dev/null
+++ b/frontend/editor/src/editoronly/hooks/useProcessorEnabled.ts
@@ -0,0 +1,4 @@
+export { isProcessorEnabled } from "@app/services/processorEnabled";
+export function useProcessorEnabled(): boolean {
+ return false;
+}
diff --git a/frontend/editor/src/editoronly/routes/adminRouteExtensions.tsx b/frontend/editor/src/editoronly/routes/adminRouteExtensions.tsx
new file mode 100644
index 0000000000..a777ebf5bf
--- /dev/null
+++ b/frontend/editor/src/editoronly/routes/adminRouteExtensions.tsx
@@ -0,0 +1 @@
+export { getAdminRouteExtensions } from "@core/routes/adminRouteExtensions";
diff --git a/frontend/editor/src/editoronly/services/policyExport.ts b/frontend/editor/src/editoronly/services/policyExport.ts
new file mode 100644
index 0000000000..3e3dcc9a1d
--- /dev/null
+++ b/frontend/editor/src/editoronly/services/policyExport.ts
@@ -0,0 +1 @@
+export { enforceExportPolicies } from "@core/services/policyExport";
diff --git a/frontend/editor/src/editoronly/services/processorEnabled.ts b/frontend/editor/src/editoronly/services/processorEnabled.ts
new file mode 100644
index 0000000000..83499a1efc
--- /dev/null
+++ b/frontend/editor/src/editoronly/services/processorEnabled.ts
@@ -0,0 +1,4 @@
+export function setProcessorEnabled(_value: boolean): void {}
+export function isProcessorEnabled(): boolean {
+ return false;
+}
diff --git a/frontend/editor/src/editoronly/tsconfig.json b/frontend/editor/src/editoronly/tsconfig.json
new file mode 100644
index 0000000000..8735dd9d7c
--- /dev/null
+++ b/frontend/editor/src/editoronly/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "paths": {
+ "@app/*": [
+ "../../src/editoronly/*",
+ "../../src/proprietary/*",
+ "../../src/core/*"
+ ],
+ "@proprietary/*": ["../../src/proprietary/*"],
+ "@core/*": ["../../src/core/*"]
+ }
+ },
+ "include": [
+ "../global.d.ts",
+ "../*.js",
+ "../*.ts",
+ "../*.tsx",
+ "../core/setupTests.ts",
+ "."
+ ]
+}
diff --git a/frontend/editor/src/proprietary/components/notifications/notificationActions.ts b/frontend/editor/src/proprietary/components/notifications/notificationActions.ts
index 5f34561a5a..1d252dbae8 100644
--- a/frontend/editor/src/proprietary/components/notifications/notificationActions.ts
+++ b/frontend/editor/src/proprietary/components/notifications/notificationActions.ts
@@ -14,6 +14,7 @@ import {
} from "@app/routes/portalBasename";
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
import { fileStorage } from "@app/services/fileStorage";
+import { isProcessorEnabled } from "@app/services/processorEnabled";
import type { FileId } from "@app/types/file";
import {
type ClientActionOutcome,
@@ -141,8 +142,8 @@ export function useNotificationActions(): ClientActionRegistry {
const viewInProcessor: ClientActionSpec = {
// Its destination is dev-only until failures get a review screen; the other half of this gate
- // is in portal/views/Documents, and both lift together.
- available: () => import.meta.env.DEV,
+ // is in portal/views/Documents, and both lift together. Never on an editor-only server.
+ available: () => import.meta.env.DEV && isProcessorEnabled(),
closesPanel: true,
run: () => navigate(FAILURES_DESTINATION),
};
diff --git a/frontend/editor/src/proprietary/components/policies/usePoliciesEnabled.ts b/frontend/editor/src/proprietary/components/policies/usePoliciesEnabled.ts
index 957913c2af..8082a381ff 100644
--- a/frontend/editor/src/proprietary/components/policies/usePoliciesEnabled.ts
+++ b/frontend/editor/src/proprietary/components/policies/usePoliciesEnabled.ts
@@ -1,3 +1,5 @@
+import { useProcessorEnabled } from "@app/hooks/useProcessorEnabled";
+
export function usePoliciesEnabled(): boolean {
- return true;
+ return useProcessorEnabled();
}
diff --git a/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx
index b9caf6ba8d..d5b4af1b81 100644
--- a/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx
+++ b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx
@@ -6,9 +6,13 @@ const h = vi.hoisted(() => ({
prefs: { loginLandingView: "processor" },
update: vi.fn(),
get: vi.fn(),
+ processorEnabled: true,
}));
vi.mock("@app/services/apiClient", () => ({ default: { get: h.get } }));
+vi.mock("@app/hooks/useProcessorEnabled", () => ({
+ useProcessorEnabled: () => h.processorEnabled,
+}));
vi.mock("@app/contexts/PreferencesContext", () => ({
usePreferences: () => ({ preferences: h.prefs, updatePreference: h.update }),
}));
@@ -61,6 +65,7 @@ beforeEach(() => {
vi.stubEnv("VITE_INCLUDE_PORTAL", "true");
vi.stubEnv("VITE_LOGIN_LANDING_MODE", "dynamic");
h.prefs = { loginLandingView: "processor" };
+ h.processorEnabled = true;
h.update.mockReset();
h.get.mockReset();
});
@@ -91,6 +96,15 @@ describe("LoginLandingSetting", () => {
expect(screen.queryByText("After signing in")).not.toBeInTheDocument();
});
+ it("renders nothing on an editor-only server", () => {
+ // Naming "Processor" here would offer a preference that never applies.
+ h.processorEnabled = false;
+ eligibleBackend();
+ renderSetting();
+ expect(screen.queryByText("After signing in")).not.toBeInTheDocument();
+ expect(h.get).not.toHaveBeenCalled();
+ });
+
it("renders nothing when the portal is not bundled", () => {
vi.stubEnv("VITE_INCLUDE_PORTAL", "");
vi.stubEnv("DEV", false);
diff --git a/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx
index 3ff1aebf64..6b6ce6adf5 100644
--- a/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx
+++ b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx
@@ -9,6 +9,7 @@ import {
isPortalAvailable,
loginLandingMode,
} from "@app/utils/loginLanding";
+import { useProcessorEnabled } from "@app/hooks/useProcessorEnabled";
/**
* Processor-user preference: where to land after signing in (processor vs
@@ -19,11 +20,14 @@ import {
export function LoginLandingSetting() {
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
+ const processorEnabled = useProcessorEnabled();
const [eligible, setEligible] = useState(false);
// Only look up eligibility when the control could actually show; skip the
- // request entirely in soft-release / no-portal builds.
- const active = loginLandingMode() === "dynamic" && isPortalAvailable();
+ // request entirely in soft-release / no-portal builds and on editor-only
+ // servers, where "Processor" would name a product that isn't running.
+ const active =
+ processorEnabled && loginLandingMode() === "dynamic" && isPortalAvailable();
useEffect(() => {
if (!active) return;
let cancelled = false;
diff --git a/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx b/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx
index 618f1e10b7..a34f4f02c6 100644
--- a/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx
+++ b/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx
@@ -2,6 +2,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import {
useConfigNavSections as useCoreConfigNavSections,
+ ConfigNavItem,
ConfigNavSection,
} from "@core/components/shared/config/configNavSections";
import PeopleSection from "@app/components/shared/config/configSections/PeopleSection";
@@ -25,6 +26,7 @@ import AdminAuditSection from "@app/components/shared/config/configSections/Admi
import AdminUsageSection from "@app/components/shared/config/configSections/AdminUsageSection";
import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection";
import AdminFolderAccessSection from "@app/components/shared/config/configSections/AdminFolderAccessSection";
+import { useProcessorEnabled } from "@app/hooks/useProcessorEnabled";
import ApiKeys from "@app/components/shared/config/configSections/ApiKeys";
import AccountSection from "@app/components/shared/config/configSections/AccountSection";
import GeneralWithLoginLanding from "@app/components/shared/config/GeneralWithLoginLanding";
@@ -40,6 +42,7 @@ export const useConfigNavSections = (
showSettingsWhenNoLogin: boolean = true,
): ConfigNavSection[] => {
const { t } = useTranslation();
+ const processorEnabled = useProcessorEnabled();
// Get the core sections (Preferences + Help)
const sections = useCoreConfigNavSections(
@@ -136,14 +139,23 @@ export const useConfigNavSections = (
badge: t("toolPanel.alpha", "Alpha"),
badgeColor: "orange",
},
- {
- key: "adminFolderAccess",
- label: t("settings.configuration.folderAccess", "Folder Access"),
- icon: "folder-rounded",
- component: ,
- disabled: requiresLogin,
- disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
- },
+ // Folder sources/outputs are Processor-only, so an editor-only server has
+ // no boundary to configure here.
+ ...(processorEnabled
+ ? ([
+ {
+ key: "adminFolderAccess",
+ label: t(
+ "settings.configuration.folderAccess",
+ "Folder Access",
+ ),
+ icon: "folder-rounded",
+ component: ,
+ disabled: requiresLogin,
+ disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
+ },
+ ] satisfies ConfigNavItem[])
+ : []),
{
key: "adminEndpoints",
label: t("settings.configuration.endpoints", "Endpoints"),
diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.tsx
index fad2782162..45d31d0960 100644
--- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.tsx
+++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.tsx
@@ -21,6 +21,7 @@ import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
+import { useProcessorEnabled } from "@app/hooks/useProcessorEnabled";
import apiClient from "@app/services/apiClient";
interface FolderAccessSettingsData {
@@ -36,6 +37,7 @@ export default function AdminFolderAccessSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
useLoginRequired();
+ const processorEnabled = useProcessorEnabled();
const {
restartModalOpened,
showRestartModal,
@@ -63,14 +65,17 @@ export default function AdminFolderAccessSection() {
}, [loginEnabled]);
useEffect(() => {
- if (!loginEnabled) return;
+ // The controller behind this is @ConditionalOnProcessor, so on an editor-only
+ // server the call would 404 into a red toast. (fetchSettings above is fine -
+ // AdminSettingsController is ungated - and gating it would hang the loader.)
+ if (!loginEnabled || !processorEnabled) return;
apiClient
.get(
"/api/v1/admin/settings/policies/implied-folder-roots",
)
.then((res) => setImpliedRoots(res.data ?? []))
.catch(() => setImpliedRoots([]));
- }, [loginEnabled]);
+ }, [loginEnabled, processorEnabled]);
const roots = settings.allowedFolderRoots ?? [];
diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx
index 3fdd597175..3b4a4550fd 100644
--- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx
+++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx
@@ -24,6 +24,7 @@ import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
+import { useProcessorEnabled } from "@app/hooks/useProcessorEnabled";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import { usePreferences } from "@app/contexts/PreferencesContext";
import { useUnsavedChanges } from "@app/contexts/UnsavedChangesContext";
@@ -77,6 +78,7 @@ export default function AdminGeneralSection() {
const location = useLocation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
+ const processorEnabled = useProcessorEnabled();
const {
restartModalOpened,
showRestartModal,
@@ -1097,147 +1099,161 @@ export default function AdminGeneralSection() {
-
- {t(
- "admin.settings.general.customPaths.pipeline.label",
- "Pipeline Directories",
- )}
-
+ {/* Every reader of these paths (pipeline processor, folder watcher,
+ Telegram bot, folder guard) is Processor-gated. */}
+ {processorEnabled && (
+ <>
+
+ {t(
+ "admin.settings.general.customPaths.pipeline.label",
+ "Pipeline Directories",
+ )}
+
-
-
-
- {t(
- "admin.settings.general.customPaths.pipeline.pipelineDir.label",
- "Pipeline Directory",
- )}
-
-
-
- }
- description={t(
- "admin.settings.general.customPaths.pipeline.pipelineDir.description",
- "Base directory for pipeline resources (leave empty for default: /pipeline)",
- )}
- value={settings.customPaths?.pipeline?.pipelineDir || ""}
- onChange={(e) =>
- setSettings({
- ...settings,
- customPaths: {
- ...settings.customPaths,
- pipeline: {
- ...settings.customPaths?.pipeline,
- pipelineDir: e.target.value,
- },
- },
- })
- }
- placeholder="/pipeline"
- disabled={!loginEnabled}
- />
-
+
+
+
+ {t(
+ "admin.settings.general.customPaths.pipeline.pipelineDir.label",
+ "Pipeline Directory",
+ )}
+
+
+
+ }
+ description={t(
+ "admin.settings.general.customPaths.pipeline.pipelineDir.description",
+ "Base directory for pipeline resources (leave empty for default: /pipeline)",
+ )}
+ value={settings.customPaths?.pipeline?.pipelineDir || ""}
+ onChange={(e) =>
+ setSettings({
+ ...settings,
+ customPaths: {
+ ...settings.customPaths,
+ pipeline: {
+ ...settings.customPaths?.pipeline,
+ pipelineDir: e.target.value,
+ },
+ },
+ })
+ }
+ placeholder="/pipeline"
+ disabled={!loginEnabled}
+ />
+
-
+
-
-
-
- {t(
- "admin.settings.general.customPaths.pipeline.finishedFoldersDir.label",
- "Finished Folders Directory",
- )}
-
-
-
- }
- description={t(
- "admin.settings.general.customPaths.pipeline.finishedFoldersDir.description",
- "Directory where processed PDFs are outputted (leave empty for default: /pipeline/finishedFolders)",
- )}
- value={settings.customPaths?.pipeline?.finishedFoldersDir || ""}
- onChange={(e) =>
- setSettings({
- ...settings,
- customPaths: {
- ...settings.customPaths,
- pipeline: {
- ...settings.customPaths?.pipeline,
- finishedFoldersDir: e.target.value,
- },
- },
- })
- }
- placeholder="/pipeline/finishedFolders"
- disabled={!loginEnabled}
- />
-
+
+
+
+ {t(
+ "admin.settings.general.customPaths.pipeline.finishedFoldersDir.label",
+ "Finished Folders Directory",
+ )}
+
+
+
+ }
+ description={t(
+ "admin.settings.general.customPaths.pipeline.finishedFoldersDir.description",
+ "Directory where processed PDFs are outputted (leave empty for default: /pipeline/finishedFolders)",
+ )}
+ value={
+ settings.customPaths?.pipeline?.finishedFoldersDir || ""
+ }
+ onChange={(e) =>
+ setSettings({
+ ...settings,
+ customPaths: {
+ ...settings.customPaths,
+ pipeline: {
+ ...settings.customPaths?.pipeline,
+ finishedFoldersDir: e.target.value,
+ },
+ },
+ })
+ }
+ placeholder="/pipeline/finishedFolders"
+ disabled={!loginEnabled}
+ />
+
+ >
+ )}
{t(
diff --git a/frontend/editor/src/proprietary/components/viewer/Viewer.tsx b/frontend/editor/src/proprietary/components/viewer/Viewer.tsx
index d5a2e7537d..600b13307e 100644
--- a/frontend/editor/src/proprietary/components/viewer/Viewer.tsx
+++ b/frontend/editor/src/proprietary/components/viewer/Viewer.tsx
@@ -9,6 +9,7 @@ import {
type PolicyRunRecord,
} from "@app/components/policies/policyRunStore";
import { isClassificationCategory } from "@app/data/classificationPolicy";
+import { useProcessorEnabled } from "@app/hooks/useProcessorEnabled";
import { PolicyEnforcementOverlay } from "@app/components/viewer/PolicyEnforcementOverlay";
type SignatureOverlayPassThrough = Pick<
@@ -25,16 +26,21 @@ type SignatureOverlayPassThrough = Pick<
const Viewer = (props: ViewerProps & SignatureOverlayPassThrough) => {
const { activeFileId } = useViewer();
const allRuns = usePolicyRuns();
+ const processorEnabled = useProcessorEnabled();
- const activeFileRuns = activeFileId
- ? allRuns.filter(
- (r: PolicyRunRecord) =>
- r.fileId === activeFileId &&
- // Classification runs async and must never block the viewer.
- !isClassificationCategory(r.categoryId) &&
- (POLICY_IN_FLIGHT_STATUSES.includes(r.status) || r.retrying === true),
- )
- : [];
+ // Runs persist in localStorage, so an editor-only server must ignore them
+ // rather than overlay a policy that can no longer be running.
+ const activeFileRuns =
+ activeFileId && processorEnabled
+ ? allRuns.filter(
+ (r: PolicyRunRecord) =>
+ r.fileId === activeFileId &&
+ // Classification runs async and must never block the viewer.
+ !isClassificationCategory(r.categoryId) &&
+ (POLICY_IN_FLIGHT_STATUSES.includes(r.status) ||
+ r.retrying === true),
+ )
+ : [];
return (
// isolation: "isolate" keeps the overlay's z-index self-contained so it
diff --git a/frontend/editor/src/proprietary/data/processorEntitySearch.ts b/frontend/editor/src/proprietary/data/processorEntitySearch.ts
index 544babf59f..a142c00b61 100644
--- a/frontend/editor/src/proprietary/data/processorEntitySearch.ts
+++ b/frontend/editor/src/proprietary/data/processorEntitySearch.ts
@@ -15,6 +15,12 @@ type EntitySearchModule = typeof import("@portal/search/entitySearch");
const includePortal =
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
+// Every import site goes through this one thunk: a single ungated import()
+// anywhere re-emits the whole portal chunk even with the flag off.
+const importEntitySearch = includePortal
+ ? () => import("@portal/search/entitySearch")
+ : null;
+
const NO_GROUPS: SuperSearchGroup[] = [];
const NO_SCOPES: readonly PortalEntityScopeId[] = [];
@@ -45,9 +51,9 @@ export function useProcessorEntityGroups(
const hasQuery = trimmed.length > 0;
useEffect(() => {
- if (!active || modRef.current) return;
+ if (!active || modRef.current || !importEntitySearch) return;
let cancelled = false;
- void import("@portal/search/entitySearch").then((loaded) => {
+ void importEntitySearch().then((loaded) => {
if (cancelled) return;
modRef.current = loaded;
setMod(loaded);
@@ -68,8 +74,9 @@ export function useProcessorEntityGroups(
const fetchScope = useCallback(
async (scopeId: PortalEntityScopeId): Promise => {
- const loaded =
- modRef.current ?? (await import("@portal/search/entitySearch"));
+ // Unreachable with the portal excluded: no scope is ever requested.
+ if (!importEntitySearch) throw new Error("portal entity search excluded");
+ const loaded = modRef.current ?? (await importEntitySearch());
return loaded.fetchPortalEntityScope(scopeId, "free");
},
[],
diff --git a/frontend/editor/src/proprietary/data/settingsSectionRegistry.ts b/frontend/editor/src/proprietary/data/settingsSectionRegistry.ts
index e72582a0b1..5bba09d612 100644
--- a/frontend/editor/src/proprietary/data/settingsSectionRegistry.ts
+++ b/frontend/editor/src/proprietary/data/settingsSectionRegistry.ts
@@ -115,6 +115,7 @@ export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
labelFallback: "Folder Access",
keywords: ["folders", "access", "permissions", "scanning"],
adminArea: true,
+ processorOnly: true,
groupLabelKey: "settings.configuration.title",
groupLabelFallback: "Configuration",
},
diff --git a/frontend/editor/src/proprietary/hooks/useClassificationEnabled.ts b/frontend/editor/src/proprietary/hooks/useClassificationEnabled.ts
index bf37cf40da..bc81c8d2b5 100644
--- a/frontend/editor/src/proprietary/hooks/useClassificationEnabled.ts
+++ b/frontend/editor/src/proprietary/hooks/useClassificationEnabled.ts
@@ -1,6 +1,9 @@
+import { useProcessorEnabled } from "@app/hooks/useProcessorEnabled";
+
// Classification is available on every proprietary-based build: the classify
-// policy labels server-side with AI on, the in-browser heuristic labels with AI off.
+// policy labels server-side with AI on, the in-browser heuristic labels with AI
+// off. Both belong to the Processor, so an editor-only server has neither.
export function useClassificationEnabled(): boolean {
- return true;
+ return useProcessorEnabled();
}
diff --git a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts
index 8bf07b5c2f..37d81b7fdf 100644
--- a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts
+++ b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts
@@ -1,15 +1,18 @@
import { useNavigate } from "react-router-dom";
import { useAuth } from "@app/auth/context";
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
+import { useProcessorEnabled } from "@app/hooks/useProcessorEnabled";
import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter";
/**
* Self-hosted: the Spring session carries `portalAccess`, so the switch to the
- * processor is offered exactly when that flag is set.
+ * processor is offered exactly when that flag is set - and never on an
+ * editor-only server, where there is no processor to switch to.
*/
export function useOtherAppSwitch(): NavFooterAppLink | null {
const { portalAccess } = useAuth();
+ const processorEnabled = useProcessorEnabled();
const navigate = useNavigate();
- if (!portalAccess) return null;
+ if (!portalAccess || !processorEnabled) return null;
return { app: "processor", onOpen: () => navigate(PORTAL_BASENAME) };
}
diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.test.ts b/frontend/editor/src/proprietary/hooks/usePolicies.test.ts
index 1a05591c4f..151aa027c6 100644
--- a/frontend/editor/src/proprietary/hooks/usePolicies.test.ts
+++ b/frontend/editor/src/proprietary/hooks/usePolicies.test.ts
@@ -36,6 +36,13 @@ vi.mock("@app/services/policyApi", () => ({
getPolicyRun: vi.fn(),
}));
+// Stubbed rather than provider-wrapped: the real hook reads app-config, which
+// this suite never fetches.
+const flags = vi.hoisted(() => ({ processorEnabled: true }));
+vi.mock("@app/hooks/useProcessorEnabled", () => ({
+ useProcessorEnabled: () => flags.processorEnabled,
+}));
+
import { usePolicies } from "@app/hooks/usePolicies";
// A minimal wizard result (workflow already saved + mapped by the builder).
@@ -68,6 +75,7 @@ describe("usePolicies", () => {
localStorage.clear();
api.store.clear();
api.seq = 0;
+ flags.processorEnabled = true;
});
it("starts with every category unconfigured (no seed)", async () => {
@@ -153,4 +161,25 @@ describe("usePolicies", () => {
});
expect(result.current.policies.ingestion.folderId).toBeTruthy();
});
+
+ it("blanks the list and skips the reconcile on an editor-only server", async () => {
+ // Enable one first, so the localStorage that outlives the flag flip is the
+ // thing being suppressed - not merely an empty start.
+ const seeded = renderHook(() => usePolicies());
+ await act(async () => {
+ await seeded.result.current.enablePolicy("security", wizardResult);
+ });
+ seeded.unmount();
+
+ flags.processorEnabled = false;
+ const { listPolicies } = await import("@app/services/policyApi");
+ vi.mocked(listPolicies).mockClear();
+
+ const { result } = renderHook(() => usePolicies());
+ await act(async () => {});
+
+ expect(result.current.policies).toEqual({});
+ expect(result.current.canConfigure).toBe(false);
+ expect(listPolicies).not.toHaveBeenCalled();
+ });
});
diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts
index 382a7f9222..57504b6ccd 100644
--- a/frontend/editor/src/proprietary/hooks/usePolicies.ts
+++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts
@@ -9,6 +9,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
+import { useProcessorEnabled } from "@app/hooks/useProcessorEnabled";
import {
loadPolicies,
onPoliciesChange,
@@ -72,10 +73,14 @@ function toStoreRequest(
};
}
+/** Editor-only server: no stored policies to reconcile against, ever. */
+const NO_POLICIES: PoliciesByCategory = {};
+
export function usePolicies() {
const [policies, setPolicies] = useState(loadPolicies);
const { config, refetch: refetchAppConfig } = useAppConfig();
const { isTeamLeader } = useSaaSTeam();
+ const processorEnabled = useProcessorEnabled();
useEffect(() => onPoliciesChange(() => setPolicies(loadPolicies())), []);
@@ -87,6 +92,9 @@ export function usePolicies() {
// locally-cached folderId; retry with backoff since the backend may not be up yet.
// On recovery, also re-resolve app config in case its admin/team-leader flags settled false while down.
useEffect(() => {
+ // No /api/v1/policies on an editor-only server - don't lean on it 404ing,
+ // the retry budget would hammer it 15 times per mount.
+ if (!processorEnabled) return;
let cancelled = false;
let attempt = 0;
let timer: ReturnType | undefined;
@@ -126,7 +134,7 @@ export function usePolicies() {
cancelled = true;
if (timer) clearTimeout(timer);
};
- }, []);
+ }, [processorEnabled]);
/**
* Enable a new policy from the wizard result: persist it to the backend (the
@@ -385,8 +393,9 @@ export function usePolicies() {
(!config.enableLogin || isTeamLeader || config.isAdmin === true);
return {
- policies,
- canConfigure,
+ // Stale localStorage outlives the flag flip, so blank the list too.
+ policies: processorEnabled ? policies : NO_POLICIES,
+ canConfigure: canConfigure && processorEnabled,
enablePolicy,
savePolicyConfig,
commitPolicyConfig,
diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts
index 85b2249cbb..fa4e0911c1 100644
--- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts
+++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts
@@ -2,11 +2,15 @@ import { useMemo, useRef } from "react";
import { usePolicyRuns } from "@app/components/policies/policyRunStore";
import type { PolicyRunRecord } from "@app/components/policies/policyRunStore";
import { useAllFiles } from "@app/contexts/FileContext";
+import { useProcessorEnabled } from "@app/hooks/useProcessorEnabled";
import { loadPolicyCatalog } from "@app/services/policyCatalog";
import { policyAccentVar } from "@app/components/policies/policyStatus";
import { isClassificationCategory } from "@app/data/classificationPolicy";
import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges";
+/** Shared empty result, so a Processor-less build hands back one identity. */
+const NO_BADGES: Map = new Map();
+
/** Minimal provenance shape needed to resolve a file's inherited badges. */
type LineageStub = {
id: string;
@@ -175,10 +179,14 @@ export function reusePolicyBadgeArrays(
* {@link reusePolicyBadgeArrays}.
*/
export function usePolicyFileBadges(): Map {
+ const processorEnabled = useProcessorEnabled();
const runs = usePolicyRuns();
const { fileStubs } = useAllFiles();
const previous = useRef