Add PROCESSOR_ENABLED flag for editor-only deployments

This commit is contained in:
Anthony Stirling
2026-08-27 13:10:29 +01:00
parent caeca0b88a
commit b57eb98bd7
136 changed files with 1818 additions and 205 deletions
+14 -2
View File
@@ -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
+21
View File
@@ -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
+21
View File
@@ -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:
@@ -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.
*
* <p>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 {}
@@ -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) {
@@ -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.
*
* <p>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.
*
* <p>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 {
/**
@@ -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<Path, WatchKey> path2KeyMapping;
@@ -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/")
@@ -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
+8
View File
@@ -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."
}
}
}
@@ -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());
@@ -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 {
@@ -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
@@ -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";
@@ -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,
@@ -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.
*
* <p>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.
*
* <p>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<String> 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<String> PROCESSOR_EXACT_PATHS =
List.of("/api/v1/ai/tools/classify-and-label");
@Test
void everyProcessorEndpointSitsOnAGatedController() throws Exception {
Set<String> 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<String, List<String>> 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<String> ungated = new TreeSet<>();
for (Class<?> controller : scanForControllers()) {
if (AnnotatedElementUtils.hasAnnotation(controller, ConditionalOnProcessor.class)) {
continue;
}
ungated.addAll(mappedPaths(controller));
}
for (Map.Entry<String, List<String>> 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<String> allPaths = new LinkedHashSet<>();
for (Class<?> controller : scanForControllers()) {
allPaths.addAll(mappedPaths(controller));
}
assertTrue(allPaths.size() > 100, "scan found only " + allPaths.size() + " endpoints");
Set<String> 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<String> mappedPaths(Class<?> controller) {
Set<String> paths = new LinkedHashSet<>();
RequestMapping base =
AnnotatedElementUtils.findMergedAnnotation(controller, RequestMapping.class);
List<String> bases = base == null ? List.of("") : pathsOf(base);
for (Method method : controller.getDeclaredMethods()) {
RequestMapping mapping =
AnnotatedElementUtils.findMergedAnnotation(method, RequestMapping.class);
if (mapping == null) {
continue;
}
List<String> suffixes = pathsOf(mapping);
for (String prefix : bases) {
for (String suffix : suffixes) {
paths.add(join(prefix, suffix));
}
}
}
return paths;
}
private static List<String> 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<Class<?>> scanForControllers() throws IOException, ClassNotFoundException {
ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(resolver);
Resource[] resources =
resolver.getResources(
"classpath*:" + SCAN_BASE_PACKAGE.replace('.', '/') + "/**/*.class");
List<Class<?>> 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");
}
}
@@ -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<Long> usersWithPortalAccess(
Collection<User> users, Set<Long> activeTeamLeaderUserIds) {
if (!processorEnabled) {
return Set.of();
}
Set<PrincipalRef> grantedPrincipals = new HashSet<>();
for (ResourceGrant g :
grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, "")) {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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.")
@@ -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 {
/**
@@ -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 {
@@ -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
@@ -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
@@ -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.")
@@ -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)
@@ -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 {
@@ -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
@@ -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
@@ -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 {
@@ -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";
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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
@@ -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 {
@@ -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<String, PolicyRun> runs = new ConcurrentHashMap<>();
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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;
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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;
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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;
@@ -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 {
@@ -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
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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
@@ -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";
@@ -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.
*
* <p>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<String> 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<String, String> 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<String> 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<String> 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<Class<?>> mainProcessorComponents() {
ClassPathScanningCandidateComponentProvider scanner =
new ClassPathScanningCandidateComponentProvider(true, environmentWithProcessorOn());
Set<Class<?>> 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);
}
}
}
+4
View File
@@ -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 \
+10 -3
View File
@@ -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<AppConfig> {
const simulated = getSimulatedAppConfig();
if (simulated) return simulated;
if (simulated) return publishConfig(simulated);
try {
const response = await apiClient.get<AppConfig>(
"/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;
}
}
@@ -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",
@@ -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(() => {
@@ -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,
];
};
@@ -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<AppConfigProviderProps> = ({
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<AppConfigContextValue>(
() => ({
config:
@@ -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);
});
});
@@ -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<string, string[]> = {
"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<string, unknown> = {
...(value as Record<string, unknown>),
};
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;
}
@@ -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. */
@@ -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
}
@@ -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;
}
@@ -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([]);
});
});
@@ -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.
@@ -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;
}
@@ -139,6 +139,8 @@ export interface MockAppApiOptions {
endpointsAvailability?: Record<string, { enabled: boolean }>;
/** 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,
},
}),
);
@@ -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/);
});
});
@@ -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";
@@ -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
@@ -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;
}
@@ -0,0 +1 @@
export { PolicyAutoRunController } from "@core/components/policies/PolicyAutoRunController";
@@ -0,0 +1 @@
export { usePoliciesEnabled } from "@core/components/policies/usePoliciesEnabled";
@@ -0,0 +1,2 @@
// No Processor -> no "after signing in" choice; render plain General settings.
export { default } from "@core/components/shared/config/configSections/GeneralSection";
@@ -0,0 +1,4 @@
// Folder sources/outputs are Processor-only; this build has none.
export default function AdminFolderAccessSection() {
return null;
}

Some files were not shown because too many files have changed in this diff Show More