mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ffc21cbca | ||
|
|
b0e1384de2 | ||
|
|
bf1ba5c1ed | ||
|
|
b57eb98bd7 |
@@ -25,6 +25,7 @@ watchedFolders/
|
||||
# The rule above targets the app's runtime watched-folders working dir, but it
|
||||
# also matches this frontend source component dir; keep the source tracked.
|
||||
!frontend/editor/src/proprietary/components/watchedFolders/
|
||||
!frontend/editor/src/editoronly/components/watchedFolders/
|
||||
clientWebUI/
|
||||
policy-webhook-spool/
|
||||
# Scratch dir used by local fixture-regeneration runs (see
|
||||
|
||||
@@ -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:
|
||||
@@ -223,6 +230,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 (pair with ProcessorFeature.ENABLED=false)"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx vite build editor --mode editoronly
|
||||
|
||||
build:saas:
|
||||
desc: "Build for SaaS mode"
|
||||
deps:
|
||||
@@ -423,6 +436,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:
|
||||
@@ -481,6 +501,7 @@ tasks:
|
||||
cmds:
|
||||
- task: typecheck:core
|
||||
- task: typecheck:proprietary
|
||||
- task: typecheck:editoronly
|
||||
- task: typecheck:saas
|
||||
- task: typecheck:desktop
|
||||
- task: typecheck:cloud
|
||||
|
||||
@@ -81,6 +81,26 @@ tasks:
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
|
||||
# The backend half is compiled in, so this only picks the editor bundle; flip
|
||||
# ProcessorFeature.ENABLED and rebuild to move the backend with it.
|
||||
dev:editoronly:
|
||||
desc: "Start the editoronly editor build (backend follows ProcessorFeature.ENABLED)"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
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:
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
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.context.annotation.Conditional;
|
||||
|
||||
import stirling.software.common.configuration.ProcessorFeature;
|
||||
|
||||
/**
|
||||
* Matches while {@link ProcessorFeature#ENABLED} is true. Applied to the Processor's controllers
|
||||
* and its background/boot-work beans; types that non-Processor code injects (stores, services, JPA
|
||||
* entities, repositories) stay ungated so the context still starts with the Processor off.
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@Conditional(ProcessorFeature.class)
|
||||
public @interface ConditionalOnProcessor {}
|
||||
@@ -0,0 +1,26 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import org.springframework.context.annotation.Condition;
|
||||
import org.springframework.context.annotation.ConditionContext;
|
||||
import org.springframework.core.type.AnnotatedTypeMetadata;
|
||||
|
||||
/**
|
||||
* The single source of truth for whether this build ships the Processor - policies, document
|
||||
* sources, classification, pipelines, triggers, integrations and the {@code /processor} portal.
|
||||
*
|
||||
* <p>Deliberately a constant rather than a property: an editor-only server is a decision made in
|
||||
* source and shipped, not something a deployment can flip. Flip {@link #ENABLED} to {@code false}
|
||||
* and rebuild to produce one.
|
||||
*
|
||||
* <p>Doubles as the Spring {@link Condition} behind {@code @ConditionalOnProcessor}, so the beans
|
||||
* and the plain-Java readers cannot disagree.
|
||||
*/
|
||||
public class ProcessorFeature implements Condition {
|
||||
|
||||
public static final boolean ENABLED = true;
|
||||
|
||||
@Override
|
||||
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
|
||||
return ENABLED;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -185,6 +185,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; set ProcessorFeature.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
|
||||
@@ -293,6 +298,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: build this JAR with ProcessorFeature.ENABLED=false, or the server keeps a Processor its UI cannot reach."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -22,6 +22,7 @@ import stirling.software.SPDF.config.InitialSetup;
|
||||
import stirling.software.SPDF.controller.api.security.TimestampController;
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.configuration.ProcessorFeature;
|
||||
import stirling.software.common.configuration.interfaces.ShowAdminInterface;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
@@ -338,6 +339,8 @@ public class ConfigController {
|
||||
// Premium/Enterprise settings
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
|
||||
configData.put("processorEnabled", ProcessorFeature.ENABLED);
|
||||
|
||||
// AI Engine settings
|
||||
ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine();
|
||||
configData.put("aiEngineEnabled", aiEngineConfig.isEnabled());
|
||||
|
||||
+4
@@ -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 {
|
||||
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+4
@@ -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";
|
||||
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
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 an editor-only build: every endpoint whose path belongs to the
|
||||
* Processor must sit on a class carrying {@link ConditionalOnProcessor}, so flipping {@code
|
||||
* ProcessorFeature.ENABLED} unmaps it.
|
||||
*
|
||||
* <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 on an editor-only build. Add"
|
||||
+ " @ConditionalOnProcessor to the controller, or - if the endpoint"
|
||||
+ " is genuinely not part of the Processor - narrow the path lists"
|
||||
+ " in this test:\n - "
|
||||
+ 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 {
|
||||
assumeTrue(proprietaryOnClasspath(), SKIP_REASON);
|
||||
// If a prefix ever swallowed one of these namespaces, editor features would vanish from an
|
||||
// editor-only server - the exact opposite of what the flag promises.
|
||||
Set<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 {
|
||||
assumeTrue(proprietaryOnClasspath(), SKIP_REASON);
|
||||
// A prefix nobody serves means the list has drifted from the code, and the guard above
|
||||
// would pass vacuously for that entry.
|
||||
Set<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 final String SKIP_REASON =
|
||||
"core flavour builds without :proprietary, so it maps none of these paths";
|
||||
|
||||
/** app/core/build.gradle only puts :proprietary on the classpath outside the core flavour. */
|
||||
private static boolean proprietaryOnClasspath() {
|
||||
try {
|
||||
Class.forName("stirling.software.proprietary.policy.controller.PolicyController");
|
||||
return true;
|
||||
} catch (ClassNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isProcessorPath(String path) {
|
||||
return PROCESSOR_EXACT_PATHS.contains(path)
|
||||
|| PROCESSOR_PATH_PREFIXES.stream().anyMatch(path::startsWith);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
int floor = proprietaryOnClasspath() ? 40 : 20;
|
||||
assertTrue(
|
||||
controllers.size() > floor,
|
||||
"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");
|
||||
}
|
||||
}
|
||||
+8
@@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.ProcessorFeature;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.access.model.AccessPermission;
|
||||
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
|
||||
@@ -40,6 +41,10 @@ public class ResourceAccessService {
|
||||
|
||||
/** 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 (!ProcessorFeature.ENABLED) {
|
||||
return false;
|
||||
}
|
||||
return canUseResource(ResourceType.PORTAL, "", null, portalDefaultPolicy, user);
|
||||
}
|
||||
|
||||
@@ -50,6 +55,9 @@ public class ResourceAccessService {
|
||||
*/
|
||||
public Set<Long> usersWithPortalAccess(
|
||||
Collection<User> users, Set<Long> activeTeamLeaderUserIds) {
|
||||
if (!ProcessorFeature.ENABLED) {
|
||||
return Set.of();
|
||||
}
|
||||
Set<PrincipalRef> grantedPrincipals = new HashSet<>();
|
||||
for (ResourceGrant g :
|
||||
grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, "")) {
|
||||
|
||||
+4
@@ -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 {
|
||||
|
||||
+2
@@ -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;
|
||||
@@ -24,6 +25,7 @@ import stirling.software.proprietary.security.service.ApiKeyManagementService;
|
||||
* keys are a core auth feature available on every self-hosted instance.
|
||||
*/
|
||||
@ProprietaryUiDataApi
|
||||
@ConditionalOnProcessor
|
||||
@RequiredArgsConstructor
|
||||
public class PortalApiKeysController {
|
||||
|
||||
|
||||
+2
@@ -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;
|
||||
@@ -25,6 +26,7 @@ import stirling.software.proprietary.service.PortalDocumentsService;
|
||||
* team (see {@link PortalDocumentsScopeResolver}).
|
||||
*/
|
||||
@ProprietaryUiDataApi
|
||||
@ConditionalOnProcessor
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("@resourceAccess.canUsePortal()")
|
||||
public class PortalDocumentsController {
|
||||
|
||||
+2
@@ -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;
|
||||
@@ -18,6 +19,7 @@ import stirling.software.proprietary.service.PortalInfraAuditService;
|
||||
|
||||
/** Serves the Infrastructure → Audit tab from real audit data, scoped and cached per caller. */
|
||||
@ProprietaryUiDataApi
|
||||
@ConditionalOnProcessor
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
public class PortalInfraAuditController {
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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.")
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
/**
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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.")
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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";
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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<>();
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+3
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+3
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+3
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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";
|
||||
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
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.Conditional;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.StandardEnvironment;
|
||||
import org.springframework.core.type.filter.AssignableTypeFilter;
|
||||
import org.springframework.data.repository.Repository;
|
||||
|
||||
import stirling.software.common.annotations.ConditionalOnProcessor;
|
||||
import stirling.software.common.configuration.ProcessorFeature;
|
||||
|
||||
/**
|
||||
* Guards {@link ProcessorFeature#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 theGateReadsTheCompileTimeConstant() {
|
||||
// The annotation is the single definition of the flag - assert its wiring directly,
|
||||
// since every other test here only asserts the annotation is present.
|
||||
Conditional conditional = ConditionalOnProcessor.class.getAnnotation(Conditional.class);
|
||||
assertNotNull(conditional, "@ConditionalOnProcessor must be a @Conditional");
|
||||
assertEquals(
|
||||
List.of(ProcessorFeature.class),
|
||||
Arrays.asList(conditional.value()),
|
||||
"@ConditionalOnProcessor must be driven by ProcessorFeature");
|
||||
assertEquals(
|
||||
ProcessorFeature.ENABLED,
|
||||
new ProcessorFeature().matches(null, null),
|
||||
"the condition must report exactly what the constant says");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stacksWithAnotherCondition() {
|
||||
// TelegramPipelineBot carries both this gate and its own @ConditionalOnProperty. Two
|
||||
// conditions on one class is subtle enough to prove rather than assume: both must pass.
|
||||
assertEquals(
|
||||
ProcessorFeature.ENABLED,
|
||||
hasBean("feature.enabled=true"),
|
||||
"with the feature on, presence must track the constant");
|
||||
assertTrue(!hasBean("feature.enabled=false"), "the other condition must still apply");
|
||||
assertTrue(!hasBean(), "an absent property must still veto");
|
||||
}
|
||||
|
||||
/** By type, not name: a nested @Configuration gets an outer-class-qualified bean name. */
|
||||
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");
|
||||
}
|
||||
|
||||
/** The scanner evaluates conditions; ProcessorFeature answers from the constant. */
|
||||
private static StandardEnvironment environmentWithProcessorOn() {
|
||||
return new StandardEnvironment();
|
||||
}
|
||||
|
||||
private static Class<?> loadClass(String name) {
|
||||
try {
|
||||
return Class.forName(name);
|
||||
} catch (ClassNotFoundException e) {
|
||||
throw new AssertionError("scanned class is not loadable: " + name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" drops the
|
||||
# Processor from the bundle and pairs with ProcessorFeature.ENABLED=false.
|
||||
ARG FRONTEND_MODE=
|
||||
|
||||
# Bundle only the JPDFium native for this image's target arch.
|
||||
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 \
|
||||
|
||||
@@ -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,28 @@ 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 {
|
||||
// Only an explicit false turns it off: the 401 fallback carries no flag, and
|
||||
// reading that as "off" would silently stop policy export enforcement.
|
||||
setProcessorEnabled(config.processorEnabled !== false);
|
||||
return config;
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
+4
-1
@@ -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(() => {
|
||||
|
||||
+5
-1
@@ -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,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { ToolId } from "@app/types/toolId";
|
||||
|
||||
export interface QuickNavHostBridgeProps {
|
||||
portalAccess?: boolean;
|
||||
processorEnabled?: boolean;
|
||||
readerMode?: boolean;
|
||||
onSetReaderMode?: (on: boolean) => void;
|
||||
onOpenSettings: () => void;
|
||||
@@ -29,6 +30,7 @@ export interface QuickNavHostBridgeProps {
|
||||
/** Registers with the rail what only the app can see, and owns the notifications panel. */
|
||||
export function QuickNavHostBridge({
|
||||
portalAccess = false,
|
||||
processorEnabled = true,
|
||||
readerMode = false,
|
||||
onSetReaderMode,
|
||||
onOpenSettings,
|
||||
@@ -58,6 +60,7 @@ export function QuickNavHostBridge({
|
||||
identity: { displayName, profilePictureUrl },
|
||||
signingBadge,
|
||||
portalAccess,
|
||||
processorEnabled,
|
||||
readerMode,
|
||||
notificationsOpen,
|
||||
toolReasons: mergedToolReasons,
|
||||
|
||||
@@ -159,7 +159,13 @@ export function QuickNavRailHost() {
|
||||
|
||||
return (
|
||||
<QuickNavRailContainer
|
||||
groups={HAS_PORTAL ? [apps, within] : [within]}
|
||||
// Bundling the portal says nothing about the server: an editor-only one has
|
||||
// no Processor to offer, so drop the group rather than show it disabled.
|
||||
groups={
|
||||
HAS_PORTAL && (inPortal || host?.processorEnabled !== false)
|
||||
? [apps, within]
|
||||
: [within]
|
||||
}
|
||||
onReturnHome={returnHome}
|
||||
identity={host?.identity ?? null}
|
||||
onOpenSettings={host?.hasSettings ? openSettings : undefined}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface QuickNavHostData {
|
||||
identity: QuickNavIdentity | null;
|
||||
signingBadge: number;
|
||||
portalAccess: boolean;
|
||||
/** False only on an editor-only server, where there is no Processor to offer. */
|
||||
processorEnabled: boolean;
|
||||
readerMode: boolean;
|
||||
/** The app owns the panel; the rail's bell only reports its state. */
|
||||
notificationsOpen: boolean;
|
||||
@@ -60,6 +62,7 @@ const EMPTY_DATA: QuickNavHostData = {
|
||||
identity: null,
|
||||
signingBadge: 0,
|
||||
portalAccess: false,
|
||||
processorEnabled: true,
|
||||
readerMode: false,
|
||||
notificationsOpen: false,
|
||||
hasSettings: false,
|
||||
@@ -89,6 +92,7 @@ export function QuickNavHostProvider({ children }: { children: ReactNode }) {
|
||||
merged.appMounted === prev.appMounted &&
|
||||
merged.signingBadge === prev.signingBadge &&
|
||||
merged.portalAccess === prev.portalAccess &&
|
||||
merged.processorEnabled === prev.processorEnabled &&
|
||||
merged.readerMode === prev.readerMode &&
|
||||
merged.notificationsOpen === prev.notificationsOpen &&
|
||||
merged.hasSettings === prev.hasSettings &&
|
||||
@@ -142,6 +146,7 @@ export function useRegisterQuickNavHost(
|
||||
identity,
|
||||
signingBadge,
|
||||
portalAccess,
|
||||
processorEnabled,
|
||||
readerMode,
|
||||
notificationsOpen,
|
||||
toolReasons,
|
||||
@@ -154,6 +159,7 @@ export function useRegisterQuickNavHost(
|
||||
identity: identity ?? null,
|
||||
signingBadge: signingBadge ?? 0,
|
||||
portalAccess: portalAccess ?? false,
|
||||
processorEnabled: processorEnabled ?? true,
|
||||
readerMode: readerMode ?? false,
|
||||
notificationsOpen: notificationsOpen ?? false,
|
||||
// Omitted when unknown, so the last answer survives a re-fetch.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
getDisabledLabel,
|
||||
} from "@app/components/tools/fullscreen/shared";
|
||||
import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
|
||||
import { useProcessorEnabled } from "@app/hooks/useProcessorEnabled";
|
||||
import { consumeReaderModeRequest } from "@app/utils/pendingReaderMode";
|
||||
import {
|
||||
FilesPageProvider,
|
||||
@@ -126,6 +127,7 @@ export default function HomePage() {
|
||||
const isProgrammaticScroll = useRef(false);
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const otherApp = useOtherAppSwitch();
|
||||
const processorEnabled = useProcessorEnabled();
|
||||
const location = useLocation();
|
||||
// Persisted user preference for the FileSidebar collapsed state. Auto-
|
||||
// collapse on /files is layered on top in the transition effect below and
|
||||
@@ -519,6 +521,7 @@ export default function HomePage() {
|
||||
<HomePageExtensions />
|
||||
<QuickNavHostBridge
|
||||
portalAccess={Boolean(otherApp)}
|
||||
processorEnabled={processorEnabled}
|
||||
onOpenSettings={() => setConfigModalOpen(true)}
|
||||
requestNavigation={requestNavigation}
|
||||
readerMode={readerMode}
|
||||
|
||||
@@ -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,240 @@
|
||||
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,
|
||||
};
|
||||
|
||||
// The quick-nav rail's Processor entry, and the sidebar footer row. Both are
|
||||
// named: an assertion on a control that no longer exists passes vacuously,
|
||||
// which is how #7695 silently defanged this file.
|
||||
const RAIL_PROCESSOR = "Processor";
|
||||
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 quick-nav rail offers no Processor entry...
|
||||
await expect(
|
||||
page.getByRole("button", { name: RAIL_PROCESSOR, exact: true }),
|
||||
).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);
|
||||
}
|
||||
});
|
||||
|
||||
// Not asserted here: whether /processor is routable depends on BUILD_PORTAL,
|
||||
// which the stubbed dev suite (portal always bundled) cannot vary. The
|
||||
// editor-only image is built with BUILD_PORTAL=false and its dist carries no
|
||||
// portal chunk - that is where the absence is verified.
|
||||
});
|
||||
|
||||
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.getByRole("button", { name: RAIL_PROCESSOR, exact: true }),
|
||||
).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";
|
||||
+4
@@ -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
Reference in New Issue
Block a user