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} + /> +
-
-