diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java
index f42a0f29c1..4a8ef53e8c 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java
@@ -4,46 +4,79 @@ import java.lang.reflect.Method;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
-import org.aspectj.lang.ProceedingJoinPoint;
-import org.aspectj.lang.annotation.Around;
-import org.aspectj.lang.annotation.Aspect;
-import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.MDC;
-import org.springframework.stereotype.Component;
-import org.springframework.web.context.request.RequestContextHolder;
-import org.springframework.web.context.request.ServletRequestAttributes;
+import jakarta.annotation.Priority;
+import jakarta.inject.Inject;
+import jakarta.interceptor.AroundInvoke;
+import jakarta.interceptor.Interceptor;
+import jakarta.interceptor.InvocationContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
-import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.service.AuditService;
-/** Aspect for processing {@link Audited} annotations. */
-@Aspect
-@Component
+/**
+ * Interceptor for processing {@link Audited} annotations.
+ *
+ *
MIGRATION (Spring AOP -> CDI interceptor): was an {@code @Aspect} {@code @Component} with
+ * {@code @Around("@annotation(...Audited)")} advice. Reworked into a CDI {@link Interceptor} bound
+ * by the {@code @Audited} annotation; {@code @Around}/{@code ProceedingJoinPoint} became
+ * {@code @AroundInvoke}/{@link InvocationContext}. Spring's {@code @Order(10)} (lower precedence,
+ * runs after {@code AutoJobAspect}) maps to {@code @Priority}: {@code AutoJobAspect} uses
+ * {@code @Priority(20)}, so this audit interceptor uses {@code @Priority(10)} which runs FIRST and
+ * populates MDC before the job interceptor - matching the original ordering intent (audit captures
+ * principal/origin/IP on the request thread before the job is dispatched).
+ *
+ *
TODO: Migration required - the {@code @Audited} annotation
+ * ({@code stirling.software.proprietary.audit.Audited}) must be made a CDI
+ * {@code @jakarta.interceptor.InterceptorBinding} (and its members marked
+ * {@code @jakarta.enterprise.util.Nonbinding}) for this {@code @Interceptor} to bind to it; see the
+ * already-migrated {@code AutoJobPostMapping}. That is a separate file and is intentionally left
+ * untouched here.
+ *
+ *
TODO: Migration required - {@code AuditService}'s helper methods
+ * ({@code createBaseAuditData}, {@code addFileData}, {@code addMethodArguments},
+ * {@code resolveEventType}) currently accept an AspectJ {@code ProceedingJoinPoint} /
+ * {@code joinPoint.getTarget()} / {@code joinPoint.getArgs()}. They must be migrated to accept a CDI
+ * {@link InvocationContext} (use {@code ctx.getTarget()}, {@code ctx.getParameters()},
+ * {@code ctx.getMethod()}). The call sites below pass {@code ctx} on that assumption.
+ */
+@Interceptor
+@Audited
+@Priority(10)
@Slf4j
-@RequiredArgsConstructor
-@org.springframework.core.annotation.Order(
- 10) // Lower precedence (higher number) - executes after AutoJobAspect
public class AuditAspect {
private final AuditService auditService;
private final AuditConfigurationProperties auditConfig;
+ private final HttpServletRequest request;
+ private final HttpServletResponse response;
- @Around("@annotation(stirling.software.proprietary.audit.Audited)")
- public Object auditMethod(ProceedingJoinPoint joinPoint) throws Throwable {
- MethodSignature signature = (MethodSignature) joinPoint.getSignature();
- Method method = signature.getMethod();
+ @Inject
+ public AuditAspect(
+ AuditService auditService,
+ AuditConfigurationProperties auditConfig,
+ HttpServletRequest request,
+ HttpServletResponse response) {
+ this.auditService = auditService;
+ this.auditConfig = auditConfig;
+ this.request = request;
+ this.response = response;
+ }
+
+ @AroundInvoke
+ public Object auditMethod(InvocationContext ctx) throws Exception {
+ Method method = ctx.getMethod();
Audited auditedAnnotation = method.getAnnotation(Audited.class);
// Fast path: use unified check to determine if we should audit
// This avoids all data collection if auditing is disabled
if (!auditService.shouldAudit(method, auditConfig)) {
- return joinPoint.proceed();
+ return ctx.proceed();
}
// EARLY CAPTURE: Try to get from MDC first (propagated from background threads)
@@ -60,9 +93,12 @@ public class AuditAspect {
capturedOrigin = auditService.captureCurrentOrigin();
}
- ServletRequestAttributes attrs =
- (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
- HttpServletRequest req = attrs != null ? attrs.getRequest() : null;
+ // MIGRATION: Spring's RequestContextHolder/ServletRequestAttributes -> CDI-injected
+ // jakarta HttpServletRequest/HttpServletResponse (quarkus-undertow). When invoked outside an
+ // HTTP request scope the injected proxy resolves to null, so we treat a null request the
+ // same way the original treated a null ServletRequestAttributes.
+ HttpServletRequest req = request;
+ boolean isHttpRequest = req != null;
String capturedIp = MDC.get("auditIp");
if (capturedIp == null) {
@@ -71,15 +107,18 @@ public class AuditAspect {
}
// Only create the map once we know we'll use it
+ // TODO: Migration required - createBaseAuditData must accept InvocationContext (ctx) once
+ // AuditService is migrated off ProceedingJoinPoint.
Map auditData =
- auditService.createBaseAuditData(joinPoint, auditedAnnotation.level());
+ auditService.createBaseAuditData(ctx, auditedAnnotation.level());
// Add HTTP information if we're in a web context
- if (attrs != null) {
+ if (isHttpRequest) {
String path = req.getRequestURI();
String httpMethod = req.getMethod();
auditService.addHttpData(auditData, httpMethod, path, auditedAnnotation.level());
- auditService.addFileData(auditData, joinPoint, auditedAnnotation.level());
+ // TODO: Migration required - addFileData must accept InvocationContext (ctx).
+ auditService.addFileData(auditData, ctx, auditedAnnotation.level());
// File operation details logged at DEBUG level for verification
if (auditData.containsKey("files") || auditData.containsKey("filename")) {
@@ -102,7 +141,8 @@ public class AuditAspect {
// Add method arguments if requested (captured at all audit levels for operational context)
if (auditedAnnotation.includeArgs()) {
- auditService.addMethodArguments(auditData, joinPoint, auditedAnnotation.level());
+ // TODO: Migration required - addMethodArguments must accept InvocationContext (ctx).
+ auditService.addMethodArguments(auditData, ctx, auditedAnnotation.level());
}
// Record start time for latency calculation
@@ -110,7 +150,7 @@ public class AuditAspect {
Object result;
try {
// Execute the method
- result = joinPoint.proceed();
+ result = ctx.proceed();
// Add success status
auditData.put("status", "success");
@@ -126,7 +166,7 @@ public class AuditAspect {
}
return result;
- } catch (Throwable ex) {
+ } catch (Exception ex) {
// Always add failure information regardless of level
auditData.put("status", "failure");
auditData.put("errorType", ex.getClass().getName());
@@ -137,23 +177,24 @@ public class AuditAspect {
} finally {
// Add timing information - use isHttpRequest=false to ensure we get timing for non-HTTP
// methods
- HttpServletResponse resp = attrs != null ? attrs.getResponse() : null;
- boolean isHttpRequest = attrs != null;
+ HttpServletResponse resp = isHttpRequest ? response : null;
auditService.addTimingData(
auditData, startTime, resp, auditedAnnotation.level(), isHttpRequest);
// Resolve the event type based on annotation and context
String httpMethod = null;
String path = null;
- if (attrs != null) {
+ if (isHttpRequest) {
httpMethod = req.getMethod();
path = req.getRequestURI();
}
+ // TODO: Migration required - resolveEventType reads joinPoint.getTarget(); once
+ // AuditService is migrated it should use ctx.getTarget().getClass() instead.
AuditEventType eventType =
auditService.resolveEventType(
method,
- joinPoint.getTarget().getClass(),
+ ctx.getTarget().getClass(),
path,
httpMethod,
auditedAnnotation);
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditDashboardWebController.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditDashboardWebController.java
index b7229cc290..06c23cff7f 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditDashboardWebController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditDashboardWebController.java
@@ -1,9 +1,13 @@
package stirling.software.proprietary.audit;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.stereotype.Controller;
-import org.springframework.ui.Model;
-import org.springframework.web.bind.annotation.GetMapping;
+import java.util.HashMap;
+import java.util.Map;
+
+import jakarta.annotation.security.RolesAllowed;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.core.Response;
import io.swagger.v3.oas.annotations.Hidden;
@@ -12,28 +16,42 @@ import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
-@Controller
-@PreAuthorize("hasRole('ADMIN')")
+@Path("")
+@ApplicationScoped
+@RolesAllowed("ADMIN")
@RequiredArgsConstructor
@EnterpriseEndpoint
public class AuditDashboardWebController {
private final AuditConfigurationProperties auditConfig;
/** Display the audit dashboard. */
- @GetMapping("/audit")
+ @GET
+ @Path("/audit")
@Hidden
- public String showDashboard(Model model) {
- model.addAttribute("auditEnabled", auditConfig.isEnabled());
- model.addAttribute("auditLevel", auditConfig.getAuditLevel());
- model.addAttribute("auditLevelInt", auditConfig.getLevel());
- model.addAttribute("retentionDays", auditConfig.getRetentionDays());
+ public Response showDashboard() {
+ // Spring's org.springframework.ui.Model + view-name ("audit/dashboard") drove Thymeleaf
+ // server-side rendering. Quarkus has no Thymeleaf view resolver; the equivalent is a Qute
+ // TemplateInstance bound to src/main/resources/templates/audit/dashboard.html.
+ // TODO: Migration required - rebind this view to Qute. Inject
+ // @io.quarkus.qute.Location("audit/dashboard") io.quarkus.qute.Template dashboard; and return
+ // dashboard.data(...) as a TemplateInstance (with a Qute RestEasy extension), or render the
+ // page client-side. The model attributes below are preserved so they can be passed to the
+ // Qute template once the audit/dashboard template is ported.
+ Map model = new HashMap<>();
+ model.put("auditEnabled", auditConfig.isEnabled());
+ model.put("auditLevel", auditConfig.getAuditLevel());
+ model.put("auditLevelInt", auditConfig.getLevel());
+ model.put("retentionDays", auditConfig.getRetentionDays());
// Add audit level enum values for display
- model.addAttribute("auditLevels", AuditLevel.values());
+ model.put("auditLevels", AuditLevel.values());
// Add audit event types for the dropdown
- model.addAttribute("auditEventTypes", AuditEventType.values());
+ model.put("auditEventTypes", AuditEventType.values());
- return "audit/dashboard";
+ // TODO: Migration required - return the rendered Qute template instead of this placeholder
+ // once audit/dashboard.html is migrated. The attributes in `model` map 1:1 to the former
+ // Spring Model attributes.
+ return Response.ok(model).build();
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java
index 0d777d9481..e30f567cfe 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java
@@ -5,91 +5,100 @@ import java.lang.reflect.Method;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
-import org.aspectj.lang.ProceedingJoinPoint;
-import org.aspectj.lang.annotation.Around;
-import org.aspectj.lang.annotation.Aspect;
-import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.MDC;
-import org.springframework.stereotype.Component;
-import org.springframework.web.bind.annotation.DeleteMapping;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PatchMapping;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.PutMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.context.request.RequestContextHolder;
-import org.springframework.web.context.request.ServletRequestAttributes;
+import jakarta.annotation.Priority;
+import jakarta.inject.Inject;
+import jakarta.interceptor.AroundInvoke;
+import jakarta.interceptor.Interceptor;
+import jakarta.interceptor.InvocationContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
-import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.service.AuditService;
/**
- * Aspect for automatically auditing controller methods with web mappings (GetMapping, PostMapping,
- * etc.)
+ * Interceptor for automatically auditing controller methods with web mappings.
+ *
+ * MIGRATION (Spring AOP -> CDI interceptor): was an {@code @Aspect}/{@code @Component} with
+ * multiple {@code @Around} advices whose pointcuts matched any method annotated with
+ * Spring's {@code @GetMapping}/{@code @PostMapping}/{@code @PutMapping}/{@code @DeleteMapping}/
+ * {@code @PatchMapping}/{@code @AutoJobPostMapping}, plus an {@code execution(...)} expression on
+ * Spring's {@code ResourceHttpRequestHandler}. {@code @Around}/{@code ProceedingJoinPoint} +
+ * {@code MethodSignature} became {@code @AroundInvoke}/{@link InvocationContext}, and
+ * {@code RequestContextHolder}/{@code ServletRequestAttributes} were replaced by an injected
+ * {@link HttpServletRequest}/{@link HttpServletResponse} (provided by quarkus-undertow). The Spring
+ * {@code @Order(0)} (highest precedence, runs before {@code AutoJobAspect}) maps to
+ * {@code @Priority} with a value lower than {@code AutoJobAspect}'s {@code @Priority(20)} so this
+ * interceptor still populates MDC first.
+ *
+ *
TODO: Migration required - CDI interceptors are bound by an {@code @InterceptorBinding}
+ * annotation declared on the target class/method; there is NO CDI equivalent for AspectJ's broad,
+ * expression-based pointcuts. The original advices fired for every Spring-MVC mapping annotation and
+ * for the static-resource handler, none of which exist on JAX-RS controllers. To retain
+ * "audit every HTTP endpoint" behaviour in Quarkus, do ONE of:
+ *
+ * register a JAX-RS {@code @Provider} pair of
+ * {@code ContainerRequestFilter}/{@code ContainerResponseFilter} (or RESTEasy Reactive
+ * {@code @ServerRequestFilter}/{@code @ServerResponseFilter}) that calls this same
+ * {@code AuditService} logic around every resource method (preferred - covers all endpoints
+ * without per-method annotations); OR
+ * introduce an explicit {@code @InterceptorBinding} (e.g. {@code @AuditedHttp}) and stamp it on
+ * the controller classes/methods that should be audited, then bind this interceptor with it.
+ *
+ * As an interim binding this interceptor is bound by the existing {@link AutoJobPostMapping}
+ * {@code @InterceptorBinding} (one of the six original pointcuts) so the class is valid CDI and
+ * still audits auto-job POST endpoints. This does NOT cover plain GET/POST/PUT/DELETE/PATCH or
+ * static-resource requests the way the Spring aspect did - that requires the JAX-RS filter or
+ * dedicated binding described above. NOTE: it must NOT be bound to {@link Audited}, because the body
+ * deliberately skips {@code @Audited} methods (those are handled by {@code AuditAspect}).
+ * The {@code auditController(...)} body below is preserved verbatim; the static-resource and
+ * static-GET-skip handling (originally driven by the {@code ResourceHttpRequestHandler} pointcut)
+ * still works via {@link AuditService#isStaticResourceRequest(HttpServletRequest)}.
*/
-@Aspect
-@Component
+@Interceptor
+@AutoJobPostMapping
+@Priority(0) // Highest precedence - runs BEFORE AutoJobAspect (@Priority(20)) to populate MDC
@Slf4j
-@RequiredArgsConstructor
-@org.springframework.core.annotation.Order(
- 0) // Highest precedence - runs BEFORE AutoJobAspect to populate MDC
public class ControllerAuditAspect {
private final AuditService auditService;
private final AuditConfigurationProperties auditConfig;
+ private final HttpServletRequest request;
+ private final HttpServletResponse response;
- @Around(
- "execution(* org.springframework.web.servlet.resource.ResourceHttpRequestHandler.handleRequest(..))")
- public Object auditStaticResource(ProceedingJoinPoint jp) throws Throwable {
- return auditController(jp, "GET");
+ @Inject
+ public ControllerAuditAspect(
+ AuditService auditService,
+ AuditConfigurationProperties auditConfig,
+ HttpServletRequest request,
+ HttpServletResponse response) {
+ this.auditService = auditService;
+ this.auditConfig = auditConfig;
+ this.request = request;
+ this.response = response;
}
- /** Intercept all methods with GetMapping annotation */
- @Around("@annotation(org.springframework.web.bind.annotation.GetMapping)")
- public Object auditGetMethod(ProceedingJoinPoint joinPoint) throws Throwable {
- return auditController(joinPoint, "GET");
+ /**
+ * TODO: Migration required - this single {@code @AroundInvoke} replaces the five Spring
+ * {@code @Around} advices (GET/POST/PUT/DELETE/PATCH + AutoJobPostMapping) and the
+ * static-resource {@code execution(...)} advice. Because CDI cannot inspect Spring/JAX-RS mapping
+ * annotations to derive the HTTP verb at bind time, the verb is resolved from the live request
+ * ({@link HttpServletRequest#getMethod()}); if the request is unavailable (non-web invocation) it
+ * falls back to POST to mirror the most common audited mapping.
+ */
+ @AroundInvoke
+ public Object auditEndpoint(InvocationContext ctx) throws Throwable {
+ String httpMethod = request != null ? request.getMethod() : "POST";
+ return auditController(ctx, httpMethod != null ? httpMethod : "POST");
}
- /** Intercept all methods with PostMapping annotation */
- @Around("@annotation(org.springframework.web.bind.annotation.PostMapping)")
- public Object auditPostMethod(ProceedingJoinPoint joinPoint) throws Throwable {
- return auditController(joinPoint, "POST");
- }
-
- /** Intercept all methods with PutMapping annotation */
- @Around("@annotation(org.springframework.web.bind.annotation.PutMapping)")
- public Object auditPutMethod(ProceedingJoinPoint joinPoint) throws Throwable {
- return auditController(joinPoint, "PUT");
- }
-
- /** Intercept all methods with DeleteMapping annotation */
- @Around("@annotation(org.springframework.web.bind.annotation.DeleteMapping)")
- public Object auditDeleteMethod(ProceedingJoinPoint joinPoint) throws Throwable {
- return auditController(joinPoint, "DELETE");
- }
-
- /** Intercept all methods with PatchMapping annotation */
- @Around("@annotation(org.springframework.web.bind.annotation.PatchMapping)")
- public Object auditPatchMethod(ProceedingJoinPoint joinPoint) throws Throwable {
- return auditController(joinPoint, "PATCH");
- }
-
- /** Intercept all methods with AutoJobPostMapping annotation */
- @Around("@annotation(stirling.software.common.annotations.AutoJobPostMapping)")
- public Object auditAutoJobMethod(ProceedingJoinPoint joinPoint) throws Throwable {
- return auditController(joinPoint, "POST");
- }
-
- private Object auditController(ProceedingJoinPoint joinPoint, String httpMethod)
- throws Throwable {
- MethodSignature sig = (MethodSignature) joinPoint.getSignature();
- Method method = sig.getMethod();
+ private Object auditController(InvocationContext joinPoint, String httpMethod) throws Throwable {
+ Method method = joinPoint.getMethod();
// Fast path: check if auditing is enabled before doing any work
// This avoids all data collection if auditing is disabled
@@ -123,10 +132,8 @@ public class ControllerAuditAspect {
}
}
- ServletRequestAttributes attrs =
- (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
- HttpServletRequest req = attrs != null ? attrs.getRequest() : null;
- HttpServletResponse resp = attrs != null ? attrs.getResponse() : null;
+ HttpServletRequest req = request;
+ HttpServletResponse resp = response;
String previousPrincipal = MDC.get("auditPrincipal");
String previousOrigin = MDC.get("auditOrigin");
@@ -163,6 +170,12 @@ public class ControllerAuditAspect {
long start = System.currentTimeMillis();
+ // TODO: Migration required (collaborator) - AuditService.createBaseAuditData/addFileData/
+ // addMethodArguments/resolveEventType still take org.aspectj.lang.ProceedingJoinPoint
+ // (AuditService is not yet migrated). Once AuditService is converted, change those
+ // signatures to accept jakarta.interceptor.InvocationContext (getMethod/getParameters/
+ // getTarget cover the data used). These calls pass the InvocationContext and will only
+ // typecheck after that collaborator change.
// Use auditService to create the base audit data
Map data = auditService.createBaseAuditData(joinPoint, level);
@@ -255,34 +268,51 @@ public class ControllerAuditAspect {
private String getRequestPath(Method method, String httpMethod) {
// Prefer actual request URI over annotation patterns (which may contain regex)
- ServletRequestAttributes attrs =
- (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
- if (attrs != null) {
- HttpServletRequest request = attrs.getRequest();
- if (request != null) {
- return request.getRequestURI();
- }
+ if (request != null) {
+ return request.getRequestURI();
}
- // Fallback: reconstruct from annotations when not in web context
+ // Fallback: reconstruct from annotations when not in web context.
+ // TODO: Migration required - the Spring @RequestMapping/@GetMapping/... fallback below relies
+ // on Spring MVC mapping annotations that no longer exist on JAX-RS controllers. Once the
+ // controllers are on JAX-RS, switch this fallback to read jakarta.ws.rs.@Path / @GET / @POST
+ // etc. (or drop it entirely if the request URI is always available). The original Spring
+ // reconstruction is preserved verbatim until then.
String base = "";
- RequestMapping cm = method.getDeclaringClass().getAnnotation(RequestMapping.class);
+ org.springframework.web.bind.annotation.RequestMapping cm =
+ method.getDeclaringClass()
+ .getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class);
if (cm != null && cm.value().length > 0) base = cm.value()[0];
String mp = "";
Annotation ann =
switch (httpMethod) {
- case "GET" -> method.getAnnotation(GetMapping.class);
- case "POST" -> method.getAnnotation(PostMapping.class);
- case "PUT" -> method.getAnnotation(PutMapping.class);
- case "DELETE" -> method.getAnnotation(DeleteMapping.class);
- case "PATCH" -> method.getAnnotation(PatchMapping.class);
+ case "GET" ->
+ method.getAnnotation(
+ org.springframework.web.bind.annotation.GetMapping.class);
+ case "POST" ->
+ method.getAnnotation(
+ org.springframework.web.bind.annotation.PostMapping.class);
+ case "PUT" ->
+ method.getAnnotation(
+ org.springframework.web.bind.annotation.PutMapping.class);
+ case "DELETE" ->
+ method.getAnnotation(
+ org.springframework.web.bind.annotation.DeleteMapping.class);
+ case "PATCH" ->
+ method.getAnnotation(
+ org.springframework.web.bind.annotation.PatchMapping.class);
default -> null;
};
- if (ann instanceof GetMapping gm && gm.value().length > 0) mp = gm.value()[0];
- if (ann instanceof PostMapping pm && pm.value().length > 0) mp = pm.value()[0];
- if (ann instanceof PutMapping pum && pum.value().length > 0) mp = pum.value()[0];
- if (ann instanceof DeleteMapping dm && dm.value().length > 0) mp = dm.value()[0];
- if (ann instanceof PatchMapping pam && pam.value().length > 0) mp = pam.value()[0];
+ if (ann instanceof org.springframework.web.bind.annotation.GetMapping gm
+ && gm.value().length > 0) mp = gm.value()[0];
+ if (ann instanceof org.springframework.web.bind.annotation.PostMapping pm
+ && pm.value().length > 0) mp = pm.value()[0];
+ if (ann instanceof org.springframework.web.bind.annotation.PutMapping pum
+ && pum.value().length > 0) mp = pum.value()[0];
+ if (ann instanceof org.springframework.web.bind.annotation.DeleteMapping dm
+ && dm.value().length > 0) mp = dm.value()[0];
+ if (ann instanceof org.springframework.web.bind.annotation.PatchMapping pam
+ && pam.value().length > 0) mp = pam.value()[0];
return base + mp;
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterLicenseGate.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterLicenseGate.java
index cc67354fec..6d2a52b0ee 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterLicenseGate.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterLicenseGate.java
@@ -1,11 +1,12 @@
package stirling.software.proprietary.cluster;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Qualifier;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.context.annotation.Configuration;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
import jakarta.annotation.PostConstruct;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Instance;
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
import lombok.extern.slf4j.Slf4j;
@@ -13,22 +14,34 @@ import lombok.extern.slf4j.Slf4j;
* Runtime license gate for cluster mode. Cluster mode requires a SERVER or ENTERPRISE license; the
* SaaS flavor bypasses (no {@code runningProOrHigher} bean is published). The Valkey connection
* config {@code @DependsOn} this bean, so it runs before any Valkey bean is constructed.
+ *
+ * TODO: Migration required - Spring @DependsOn ordering relative to the Valkey connection config
+ * has no direct Quarkus equivalent. Ensure the Valkey/Redis bean either @Inject's this gate or that
+ * this @PostConstruct verification still runs before any Valkey bean is constructed (e.g. via a
+ * Startup observer ordering or an explicit dependency).
*/
-@Configuration
-@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true")
+@ApplicationScoped
@Slf4j
public class ClusterLicenseGate {
- @Autowired(required = false)
- @Qualifier("runningProOrHigher")
- private Boolean runningProOrHigher;
+ // @ConditionalOnProperty(name = "cluster.enabled", havingValue = "true") -> runtime guard below.
+ @ConfigProperty(name = "cluster.enabled", defaultValue = "false")
+ boolean clusterEnabled;
+
+ // @Autowired(required = false) @Qualifier("runningProOrHigher") -> optional named lookup.
+ @Inject
+ @Named("runningProOrHigher")
+ Instance runningProOrHigher;
@PostConstruct
void verifyLicense() {
- if (runningProOrHigher == null) {
+ if (!clusterEnabled) {
+ return; // cluster mode disabled - gate not applicable
+ }
+ if (!runningProOrHigher.isResolvable()) {
return; // saas flavor - licensed via Stripe elsewhere
}
- if (!runningProOrHigher) {
+ if (!runningProOrHigher.get()) {
throw new IllegalStateException(
"Cluster mode (cluster.enabled=true) requires a SERVER or"
+ " ENTERPRISE license. Configure stirling.premium.key with a valid"
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterMetrics.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterMetrics.java
index a97c3bb906..3f3e42cb45 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterMetrics.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterMetrics.java
@@ -4,8 +4,8 @@ import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.stereotype.Component;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
@@ -19,8 +19,12 @@ import stirling.software.common.model.ApplicationProperties;
* Cluster operation metrics exposed via {@code /actuator/prometheus}. Registered only when cluster
* mode is on.
*/
-@Component
-@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true")
+// TODO: Migration required - original @ConditionalOnProperty(name = "cluster.enabled",
+// havingValue = "true") was a runtime toggle. Quarkus @IfBuildProfile/@LookupIfProperty are
+// build-time only. Either gate registration with a runtime guard on
+// applicationProperties.getCluster().isEnabled() (e.g. skip meter registration when disabled),
+// or use @io.quarkus.arc.lookup.LookupIfProperty if a build-time switch is acceptable.
+@ApplicationScoped
public class ClusterMetrics implements StickyMissRecorder {
private final MeterRegistry registry;
@@ -38,6 +42,7 @@ public class ClusterMetrics implements StickyMissRecorder {
private final AtomicLong jobsInflight = new AtomicLong();
+ @Inject
public ClusterMetrics(MeterRegistry registry, ApplicationProperties applicationProperties) {
this.registry = registry;
this.applicationProperties = applicationProperties;
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterNodeBootstrap.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterNodeBootstrap.java
index ccd399410e..c70e8f7f7b 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterNodeBootstrap.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterNodeBootstrap.java
@@ -6,13 +6,16 @@ import java.time.Duration;
import java.time.Instant;
import java.util.Locale;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.boot.context.event.ApplicationReadyEvent;
-import org.springframework.context.SmartLifecycle;
-import org.springframework.context.event.EventListener;
-import org.springframework.scheduling.annotation.Scheduled;
-import org.springframework.stereotype.Component;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.event.Observes;
+import jakarta.inject.Inject;
+
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+
+import io.quarkus.runtime.StartupEvent;
+import io.quarkus.scheduler.Scheduled;
import lombok.extern.slf4j.Slf4j;
@@ -25,31 +28,49 @@ import stirling.software.common.model.ApplicationProperties.Cluster;
* Registers the local node with {@link InstanceRegistry} on startup, refreshes the entry at 1/3 of
* the TTL, and deregisters cleanly on shutdown.
*
- * Implements {@link SmartLifecycle} with {@code getPhase() == Integer.MAX_VALUE} so Spring tears
- * this bean down before {@code LettuceConnectionFactory} - deregister therefore runs while the
- * Valkey connection is still alive.
+ *
Originally implemented Spring's {@code SmartLifecycle} with {@code getPhase() ==
+ * Integer.MAX_VALUE} so Spring tore this bean down before {@code LettuceConnectionFactory} -
+ * deregister therefore ran while the Valkey connection was still alive.
+ *
+ *
TODO: Migration required - Quarkus has no SmartLifecycle/getPhase shutdown-ordering
+ * equivalent. Startup now runs via @Observes StartupEvent and shutdown via @PreDestroy. If the
+ * Quarkus Redis/Valkey client is torn down before this bean's @PreDestroy, the deregister call may
+ * fail (it already tolerates that via TTL expiry). If strict ordering is required, observe
+ * io.quarkus.runtime.ShutdownEvent on a bean ordered ahead of the Redis client, or rely on the
+ * heartbeat TTL to clean up the stale entry.
*/
-@Component
+@ApplicationScoped
@Slf4j
-@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true")
-public class ClusterNodeBootstrap implements SmartLifecycle {
+public class ClusterNodeBootstrap {
- private final Duration heartbeatTtl;
+ // TODO: Migration required - Spring @ConditionalOnProperty(name = "cluster.enabled",
+ // havingValue = "true") was a runtime toggle. Quarkus build-time conditionals
+ // (@IfBuildProfile / @LookupIfProperty) cannot gate a StartupEvent observer at runtime, so the
+ // bean is always instantiated and the toggle is enforced at runtime via clusterEnabled below.
+ @ConfigProperty(name = "cluster.enabled", defaultValue = "false")
+ boolean clusterEnabled;
+
+ private Duration heartbeatTtl;
private final ApplicationProperties applicationProperties;
private final InstanceRegistry instanceRegistry;
- @Value("${server.port:8080}")
- private int serverPort;
+ @ConfigProperty(name = "server.port", defaultValue = "8080")
+ int serverPort;
private volatile String nodeId;
private volatile String internalAddress;
private volatile boolean running = false;
+ @Inject
public ClusterNodeBootstrap(
ApplicationProperties applicationProperties, InstanceRegistry instanceRegistry) {
this.applicationProperties = applicationProperties;
this.instanceRegistry = instanceRegistry;
+ }
+
+ @PostConstruct
+ void init() {
Cluster cluster = applicationProperties.getCluster();
// Default must match the @Scheduled fallback below AND the model default
// (ApplicationProperties.Cluster.Node.heartbeatIntervalMs = 5000); otherwise the TTL is
@@ -60,18 +81,30 @@ public class ClusterNodeBootstrap implements SmartLifecycle {
this.heartbeatTtl = Duration.ofMillis(heartbeatMs * 3);
}
- @EventListener(ApplicationReadyEvent.class)
- public void registerOnStartup() {
+ void registerOnStartup(@Observes StartupEvent event) {
+ if (!clusterEnabled) {
+ return;
+ }
nodeId = applicationProperties.getCluster().resolvedNodeId();
internalAddress = resolveInternalAddress();
+ running = true;
registerSelf("register");
}
- @Scheduled(fixedDelayString = "${cluster.node.heartbeat-interval-ms:5000}")
+ // TODO: Migration required - Spring @Scheduled(fixedDelayString =
+ // "${cluster.node.heartbeat-interval-ms:5000}") drove the interval directly from config in
+ // milliseconds. Quarkus @Scheduled "every" expects a Duration string, so the config reference
+ // "{cluster.node.heartbeat-interval-ms}" cannot be reused as-is (it resolves to a bare number).
+ // Hard-coded to 5s to match the model default; if the interval is operator-tunable, expose a
+ // duration-formatted property (e.g. cluster.node.heartbeat-interval=5s) and reference it here.
+ @Scheduled(every = "5s")
public void heartbeat() {
- // Heartbeat-after-stop race: SmartLifecycle.stop() deregisters, but the @Scheduled
- // tick keeps firing during a slow drain. Without this guard, the next tick re-registers
- // the dead node and the entry resurfaces in the registry until TTL expiry.
+ if (!clusterEnabled) {
+ return;
+ }
+ // Heartbeat-after-stop race: shutdown deregisters, but the @Scheduled tick keeps firing
+ // during a slow drain. Without this guard, the next tick re-registers the dead node and
+ // the entry resurfaces in the registry until TTL expiry.
if (!running) {
return;
}
@@ -100,13 +133,8 @@ public class ClusterNodeBootstrap implements SmartLifecycle {
}
}
- @Override
- public void start() {
- running = true;
- }
-
- @Override
- public void stop() {
+ @PreDestroy
+ void stop() {
running = false;
if (nodeId == null) {
return;
@@ -124,21 +152,10 @@ public class ClusterNodeBootstrap implements SmartLifecycle {
}
}
- @Override
public boolean isRunning() {
return running;
}
- @Override
- public int getPhase() {
- return Integer.MAX_VALUE;
- }
-
- @Override
- public boolean isAutoStartup() {
- return true;
- }
-
/**
* Resolve the address peers should hit. Order: explicit config -> {@code POD_IP} env (K8s
* downward API) -> JDK hostname -> fail loud (never silently fall back to a loopback).
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStoreConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStoreConfiguration.java
index b2a516a4e6..fca027fcf4 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStoreConfiguration.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStoreConfiguration.java
@@ -1,10 +1,12 @@
package stirling.software.proprietary.cluster.s3;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Disposes;
+import jakarta.enterprise.inject.Produces;
+
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+
+import io.quarkus.arc.lookup.LookupIfProperty;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -13,17 +15,29 @@ import stirling.software.common.cluster.FileStore;
import stirling.software.common.model.ApplicationProperties;
/** Activates the S3-backed transient {@link FileStore} when {@code cluster.artifactStore=s3}. */
+// TODO: Migration required - the original Spring class was guarded by
+// @ConditionalOnProperty(prefix="cluster", name="artifactStore", havingValue="s3") and
+// @ConditionalOnMissingBean on the @Bean. The S3 producer below is gated with
+// @io.quarkus.arc.lookup.LookupIfProperty(name="cluster.artifactStore", stringValue="s3"), which
+// only contributes this FileStore when the property is "s3"; the always-on @DefaultBean producer in
+// common's LocalDiskFileStoreConfiguration covers the "local"/default case, so S3 here wins (a
+// non-default producer beats @DefaultBean) only when the property selects it - preserving the
+// original @ConditionalOnMissingBean intent. Note: @LookupIfProperty is evaluated at build time, so
+// the artifact store cannot be switched at runtime. If a true runtime toggle is required, drop the
+// annotation and gate the producer body on the config value instead.
@Slf4j
-@Configuration
+@ApplicationScoped
@RequiredArgsConstructor
-@ConditionalOnProperty(prefix = "cluster", name = "artifactStore", havingValue = "s3")
public class S3FileStoreConfiguration {
private final ApplicationProperties applicationProperties;
- @Bean(destroyMethod = "close")
- @ConditionalOnMissingBean
- public FileStore fileStore(@Value("${cluster.s3.keyPrefix:transient/}") String keyPrefix) {
+ @Produces
+ @ApplicationScoped
+ @LookupIfProperty(name = "cluster.artifactStore", stringValue = "s3")
+ public FileStore fileStore(
+ @ConfigProperty(name = "cluster.s3.keyPrefix", defaultValue = "transient/")
+ String keyPrefix) {
ApplicationProperties.Storage.S3 cfg = applicationProperties.getStorage().getS3();
S3Clients.Bundle bundle = S3Clients.build(cfg, "cluster file store");
// FileStore has no signed-URL contract; close the unused presigner immediately.
@@ -34,4 +48,18 @@ public class S3FileStoreConfiguration {
log.info("Cluster FileStore: s3 (bucket={}, keyPrefix={})", cfg.getBucket(), keyPrefix);
return new S3FileStore(bundle.client(), cfg.getBucket(), keyPrefix, true);
}
+
+ /**
+ * Replaces the Spring {@code @Bean(destroyMethod = "close")} contract: CDI does not auto-invoke
+ * close() on producer-created beans, so this disposer closes the {@link S3FileStore} when the
+ * bean is destroyed.
+ */
+ void closeFileStore(@Disposes FileStore fileStore) {
+ if (fileStore instanceof S3FileStore s3FileStore) {
+ try {
+ s3FileStore.close();
+ } catch (Exception ignored) {
+ }
+ }
+ }
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ConditionalOnValkeyBackplane.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ConditionalOnValkeyBackplane.java
index 4651cf6f27..1b4a5de7fa 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ConditionalOnValkeyBackplane.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ConditionalOnValkeyBackplane.java
@@ -5,15 +5,33 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
+import io.quarkus.arc.lookup.LookupIfProperty;
/**
* Composite condition: matches only when cluster.enabled=true AND cluster.backplane=valkey. Both
* checks are required (enabled alone may select the in-process backplane, which must not load
- * Valkey beans); a single {@code @ConditionalOnExpression} keeps the guard in one place.
+ * Valkey beans); a single guard keeps the condition in one place.
+ *
+ *
The original Spring annotation used a single
+ * {@code @ConditionalOnExpression("${cluster.enabled:false} and
+ * '${cluster.backplane:inprocess}'.equals('valkey')")} SpEL guard. Quarkus/CDI has no SpEL-based
+ * conditional, but the boolean AND of two simple property checks maps directly onto two stacked
+ * (repeatable) {@link LookupIfProperty} annotations, which are evaluated with AND semantics. The
+ * Valkey producer beans are looked up only when both properties hold; otherwise the
+ * {@code @DefaultBean} in-process implementations win.
+ *
+ *
TODO: Migration required - in Spring this was a composite meta-annotation: placing
+ * {@code @ConditionalOnValkeyBackplane} on a bean transitively applied the underlying
+ * {@code @ConditionalOnExpression}. Quarkus does NOT transitively propagate {@link LookupIfProperty}
+ * through a custom meta-annotation, so the two {@code @LookupIfProperty} guards below are documentary
+ * only - each consumer of this annotation (ValkeyClusterBackplane, ValkeyJobStore,
+ * ValkeyRateLimitStore, ValkeyDistributedLock, ValkeyKeyValueCache, ValkeyInstanceRegistry) must
+ * also carry the two {@code @LookupIfProperty} guards directly (or be produced via a producer method
+ * carrying them). Defaults: cluster.enabled defaults to false and cluster.backplane defaults to
+ * inprocess, so absent both properties the Valkey beans stay disabled.
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
-@ConditionalOnExpression(
- "${cluster.enabled:false} and '${cluster.backplane:inprocess}'.equals('valkey')")
+@LookupIfProperty(name = "cluster.enabled", stringValue = "true")
+@LookupIfProperty(name = "cluster.backplane", stringValue = "valkey")
public @interface ConditionalOnValkeyBackplane {}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyClusterBackplane.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyClusterBackplane.java
index cf7bfb61c4..14e1b0724c 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyClusterBackplane.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyClusterBackplane.java
@@ -1,32 +1,47 @@
package stirling.software.proprietary.cluster.valkey;
-import org.springframework.data.redis.core.RedisCallback;
-import org.springframework.data.redis.core.StringRedisTemplate;
-import org.springframework.stereotype.Component;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+import io.quarkus.redis.datasource.RedisDataSource;
-import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.model.ApplicationProperties;
@Slf4j
-@Component
-@RequiredArgsConstructor
-@ConditionalOnValkeyBackplane
+@ApplicationScoped
+// TODO: Migration required - @ConditionalOnValkeyBackplane was a Spring @ConditionalOnExpression
+// guard ("cluster.enabled=true AND cluster.backplane=valkey"). Quarkus has no runtime
+// @Conditional for beans; this bean is now always instantiated. Gate selection at runtime
+// (e.g. a ClusterBackplane producer that picks valkey vs in-process based on injected config),
+// or use @io.quarkus.arc.lookup.LookupIfProperty(name="cluster.backplane", stringValue="valkey")
+// (build-time/static only - does not also check cluster.enabled). The composite condition must be
+// re-expressed accordingly.
public class ValkeyClusterBackplane implements ClusterBackplane {
- private final ApplicationProperties applicationProperties;
- private final StringRedisTemplate template;
+ @Inject
+ ApplicationProperties applicationProperties;
+
+ // TODO: Migration required - was Spring spring-data-redis StringRedisTemplate. Replaced with
+ // Quarkus RedisDataSource (io.quarkus.redis.datasource). Verify the redis client extension
+ // (quarkus-redis-client) is on the classpath and configured via quarkus.redis.* properties.
+ @Inject
+ RedisDataSource redisDataSource;
@Override
public boolean isHealthy() {
try {
- // template.execute() borrows from the pool and returns the connection in a finally
- // block - critical because isHealthy() is hit on every k8s liveness/readiness probe
- // tick. Calling getConnectionFactory().getConnection() directly leaks the connection
- // and exhausts the pool under monitoring load.
- String pong = template.execute((RedisCallback) connection -> connection.ping());
+ // Original used template.execute() so the connection was borrowed from the pool and
+ // returned in a finally block - critical because isHealthy() is hit on every k8s
+ // liveness/readiness probe tick. Quarkus RedisDataSource manages connection
+ // pooling/return internally, so issuing a single command (PING) is the equivalent.
+ // TODO: Migration required - confirm command mapping. Quarkus exposes PING via the
+ // low-level command API: redisDataSource.execute("PING") returns a Response whose
+ // toString() is the simple-string reply "PONG". Validate this against the actual
+ // RedisDataSource API version in use.
+ String pong = redisDataSource.execute("PING").toString();
return "PONG".equalsIgnoreCase(pong);
} catch (RuntimeException ex) {
log.warn("Valkey backplane health check failed: {}", ex.getMessage());
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyConnectionConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyConnectionConfiguration.java
index ce121ec66d..03aeef1f3a 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyConnectionConfiguration.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyConnectionConfiguration.java
@@ -4,10 +4,6 @@ import java.net.URI;
import java.net.URISyntaxException;
import java.time.Duration;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.context.annotation.DependsOn;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisPassword;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
@@ -18,23 +14,55 @@ import org.springframework.data.redis.core.StringRedisTemplate;
import io.lettuce.core.RedisCommandExecutionException;
import io.lettuce.core.SslVerifyMode;
+import io.quarkus.arc.lookup.LookupIfProperty;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Produces;
+import jakarta.inject.Named;
+
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Cluster;
+// TODO: Migration required - this class still depends on spring-data-redis types
+// (LettuceConnectionFactory, StringRedisTemplate, RedisStandaloneConfiguration,
+// LettuceClientConfiguration, RedisPassword, RedisConnection). Quarkus has no spring-data-redis;
+// the backplane should be reworked onto io.quarkus.redis.datasource.RedisDataSource /
+// ReactiveRedisDataSource configured via quarkus.redis.* in application.properties (hosts, password,
+// tls, timeout=2s). The produced beans below are consumed by ValkeyClusterBackplane and the other
+// Valkey* collaborators in this package; migrating this file requires migrating those consumers in
+// lockstep, so the spring-data-redis imports are retained until that coordinated change lands. The
+// pure URL-parsing / handshake / auth-detection helpers (parseUrl, buildClientConfiguration,
+// eagerHandshake, isAuthFailure) are framework-agnostic and carry over unchanged.
+//
+// DI/config mapping applied here:
+// @Configuration -> @ApplicationScoped (producer bean class)
+// @Bean -> @Produces (+ @Named for the StringRedisTemplate)
+// @ConditionalOnProperty(cluster.enabled)-> @LookupIfProperty(name="cluster.enabled", stringValue="true")
+// @ConditionalOnProperty(backplane=valkey)-> @LookupIfProperty(name="cluster.backplane", stringValue="valkey")
+// @DependsOn("clusterLicenseGate") -> TODO: ordering; ensure clusterLicenseGate runs first
+// (CDI has no @DependsOn; use @Observes ordering or an
+// explicit @Inject of the gate bean once migrated).
+// @Bean(destroyMethod="destroy") -> @PreDestroy on the produced instance is not expressible
+// on a @Produces method here; rely on factory.destroy()
+// already wired via Spring's destroy lifecycle until the
+// RedisDataSource migration removes this bean. TODO.
@Slf4j
-@Configuration
+@ApplicationScoped
@RequiredArgsConstructor
-@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true")
-@DependsOn("clusterLicenseGate")
+@LookupIfProperty(name = "cluster.enabled", stringValue = "true")
public class ValkeyConnectionConfiguration {
private final ApplicationProperties applicationProperties;
- @Bean(destroyMethod = "destroy")
- @ConditionalOnProperty(name = "cluster.backplane", havingValue = "valkey")
+ // TODO: Migration required - replace LettuceConnectionFactory with a configured
+ // io.quarkus.redis.datasource.RedisDataSource (quarkus.redis.* config). destroyMethod="destroy"
+ // has no @Produces equivalent without a @Disposes method; keep factory.destroy() lifecycle until
+ // the RedisDataSource migration.
+ @Produces
+ @LookupIfProperty(name = "cluster.backplane", stringValue = "valkey")
public LettuceConnectionFactory valkeyConnectionFactory() {
Cluster cluster = applicationProperties.getCluster();
Endpoint endpoint = parseUrl(cluster.getValkey().getUrl());
@@ -257,8 +285,12 @@ public class ValkeyConnectionConfiguration {
return t.getMessage();
}
- @Bean
- @ConditionalOnProperty(name = "cluster.backplane", havingValue = "valkey")
+ // TODO: Migration required - StringRedisTemplate is spring-data-redis. Once the connection
+ // migrates to RedisDataSource, this producer should be removed and consumers should inject the
+ // Quarkus RedisDataSource (string commands via redisDataSource.value(String.class)) directly.
+ @Produces
+ @Named("valkeyTemplate")
+ @LookupIfProperty(name = "cluster.backplane", stringValue = "valkey")
public StringRedisTemplate valkeyTemplate(LettuceConnectionFactory factory) {
return new StringRedisTemplate(factory);
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyDistributedLock.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyDistributedLock.java
index 0a70391c03..146bc38e8a 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyDistributedLock.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyDistributedLock.java
@@ -8,16 +8,36 @@ import java.util.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
-import org.springframework.stereotype.Component;
-import lombok.RequiredArgsConstructor;
+import io.quarkus.arc.lookup.LookupIfProperty;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.DistributedLock;
-@Component
-@RequiredArgsConstructor
-@ConditionalOnValkeyBackplane
+// DI mapping applied here:
+// @Component -> @ApplicationScoped
+// @RequiredArgsConstructor -> explicit @Inject constructor (single injected collaborator)
+// @ConditionalOnValkeyBackplane -> the two stacked @LookupIfProperty guards below (per the note in
+// ConditionalOnValkeyBackplane: Quarkus does not transitively
+// propagate @LookupIfProperty through the meta-annotation, so the
+// guards are repeated directly on this consumer).
+//
+// TODO: Migration required - this class still depends on spring-data-redis types
+// (StringRedisTemplate, RedisScript, DefaultRedisScript). Quarkus has no spring-data-redis; once
+// ValkeyConnectionConfiguration migrates its producer onto io.quarkus.redis.datasource.RedisDataSource,
+// this lock should be reworked to use RedisDataSource: SET NX PX for tryAcquire and EVAL of the
+// release/renew Lua scripts (redisDataSource.execute("EVAL", script, "1", key, value[, ttlMillis])).
+// The injected bean is the @Named("valkeyTemplate") StringRedisTemplate produced there, so this file
+// and that producer must migrate in lockstep; the spring-data-redis imports are retained until then.
+// The Lua scripts and the acquire/release/renew control flow are framework-agnostic and carry over.
+@ApplicationScoped
+@LookupIfProperty(name = "cluster.enabled", stringValue = "true")
+@LookupIfProperty(name = "cluster.backplane", stringValue = "valkey")
@Slf4j
public class ValkeyDistributedLock implements DistributedLock {
@@ -35,6 +55,11 @@ public class ValkeyDistributedLock implements DistributedLock {
private final StringRedisTemplate template;
+ @Inject
+ public ValkeyDistributedLock(@Named("valkeyTemplate") StringRedisTemplate template) {
+ this.template = template;
+ }
+
@Override
public Optional tryAcquire(String lockKey, Duration leaseTime) {
String key = PREFIX + lockKey;
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyInstanceRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyInstanceRegistry.java
index a8ff8d60ec..83788b0a3e 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyInstanceRegistry.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyInstanceRegistry.java
@@ -1,6 +1,5 @@
package stirling.software.proprietary.cluster.valkey;
-import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
@@ -10,11 +9,14 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
-import org.springframework.data.redis.core.Cursor;
-import org.springframework.data.redis.core.RedisCallback;
-import org.springframework.data.redis.core.ScanOptions;
-import org.springframework.data.redis.core.StringRedisTemplate;
-import org.springframework.stereotype.Component;
+import io.quarkus.redis.datasource.RedisDataSource;
+import io.quarkus.redis.datasource.hash.HashCommands;
+import io.quarkus.redis.datasource.keys.KeyCommands;
+import io.quarkus.redis.datasource.keys.KeyScanCursor;
+import io.quarkus.redis.datasource.keys.ScanArgs;
+import io.quarkus.redis.datasource.transactions.TransactionResult;
+
+import jakarta.enterprise.context.ApplicationScoped;
import lombok.RequiredArgsConstructor;
@@ -25,14 +27,17 @@ import stirling.software.common.cluster.InstanceRegistry;
* Valkey-backed {@link InstanceRegistry}. Each node is stored as a hash with a TTL equal to the
* configured heartbeat TTL; the heartbeat re-arms the TTL.
*/
-@Component
+// TODO: Migration required - the original @ConditionalOnValkeyBackplane (cluster.enabled=true AND
+// cluster.backplane=valkey) was a runtime toggle. Quarkus build-time conditions (@IfBuildProfile /
+// @LookupIfProperty) cannot express this composite runtime expression. Guard producer/usage at
+// runtime via the Config values, or rework ConditionalOnValkeyBackplane into a CDI lookup guard.
+@ApplicationScoped
@RequiredArgsConstructor
-@ConditionalOnValkeyBackplane
public class ValkeyInstanceRegistry implements InstanceRegistry {
private static final String PREFIX = "stirling:nodes:";
- private final StringRedisTemplate template;
+ private final RedisDataSource redis;
@Override
public void register(ClusterNode node, Duration heartbeatTtl) {
@@ -47,22 +52,14 @@ public class ValkeyInstanceRegistry implements InstanceRegistry {
// MULTI/EXEC so the hash fields and the TTL commit together. Without this, a crash
// between HSET and EXPIRE leaves the hash with no TTL: it never expires, masks the
// dead node as alive, and only a subsequent successful register() would re-arm it.
- template.execute(
- (RedisCallback)
- connection -> {
- connection.multi();
- byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
- Map hashBytes = new LinkedHashMap<>();
- for (Map.Entry f : fields.entrySet()) {
- hashBytes.put(
- f.getKey().getBytes(StandardCharsets.UTF_8),
- f.getValue().getBytes(StandardCharsets.UTF_8));
- }
- connection.hashCommands().hMSet(keyBytes, hashBytes);
- connection.keyCommands().pExpire(keyBytes, ttlMs);
- connection.exec();
- return null;
+ TransactionResult result =
+ redis.withTransaction(
+ tx -> {
+ tx.hash(String.class).hset(key, fields);
+ tx.key(String.class).pexpire(key, ttlMs);
});
+ // result.discarded() would be true if the transaction was aborted; the heartbeat will
+ // re-arm on the next register() so we do not fail hard here.
}
@Override
@@ -72,11 +69,13 @@ public class ValkeyInstanceRegistry implements InstanceRegistry {
@Override
public Collection activeNodes() {
- ScanOptions options = ScanOptions.scanOptions().match(PREFIX + "*").count(256).build();
List nodes = new ArrayList<>();
- try (Cursor cursor = template.scan(options)) {
- while (cursor.hasNext()) {
- readNode(cursor.next()).ifPresent(nodes::add);
+ KeyCommands keys = redis.key(String.class);
+ KeyScanCursor cursor =
+ keys.scan(new ScanArgs().match(PREFIX + "*").count(256));
+ while (cursor.hasNext()) {
+ for (String key : cursor.next()) {
+ readNode(key).ifPresent(nodes::add);
}
}
return nodes;
@@ -84,32 +83,33 @@ public class ValkeyInstanceRegistry implements InstanceRegistry {
@Override
public void deregister(String nodeId) {
- template.delete(PREFIX + nodeId);
+ redis.key(String.class).del(PREFIX + nodeId);
}
private Optional readNode(String key) {
- Map entries = template.opsForHash().entries(key);
+ HashCommands hash = redis.hash(String.class);
+ Map entries = hash.hgetall(key);
if (entries == null || entries.isEmpty()) {
return Optional.empty();
}
- Object nodeId = entries.get("nodeId");
+ String nodeId = entries.get("nodeId");
if (nodeId == null) {
return Optional.empty();
}
Instant heartbeat = Instant.now();
- Object hb = entries.get("lastHeartbeat");
+ String hb = entries.get("lastHeartbeat");
if (hb != null) {
try {
- heartbeat = Instant.parse(hb.toString());
+ heartbeat = Instant.parse(hb);
} catch (RuntimeException ignored) {
// keep default
}
}
return Optional.of(
new ClusterNode(
- nodeId.toString(),
- String.valueOf(entries.getOrDefault("internalAddress", "")),
+ nodeId,
+ entries.getOrDefault("internalAddress", ""),
heartbeat,
- String.valueOf(entries.getOrDefault("role", "BOTH"))));
+ entries.getOrDefault("role", "BOTH")));
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java
index 8e93871859..f4afc57bb1 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java
@@ -11,17 +11,22 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
-import org.springframework.data.redis.core.Cursor;
-import org.springframework.data.redis.core.RedisCallback;
-import org.springframework.data.redis.core.ScanOptions;
-import org.springframework.data.redis.core.StringRedisTemplate;
-import org.springframework.stereotype.Component;
+import jakarta.enterprise.context.ApplicationScoped;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
-import lombok.RequiredArgsConstructor;
+import io.quarkus.redis.datasource.RedisDataSource;
+import io.quarkus.redis.datasource.hash.HashCommands;
+import io.quarkus.redis.datasource.keys.KeyCommands;
+import io.quarkus.redis.datasource.keys.KeyScanArgs;
+import io.quarkus.redis.datasource.keys.KeyScanCursor;
+import io.quarkus.redis.datasource.transactions.OptimisticLockingTransactionResult;
+import io.quarkus.redis.datasource.transactions.TransactionResult;
+import io.quarkus.redis.datasource.value.SetArgs;
+import io.quarkus.redis.datasource.value.ValueCommands;
+
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.JobStore;
@@ -31,11 +36,16 @@ import stirling.software.common.cluster.JobStoreEntry;
* Valkey-backed {@link JobStore}. Each job is one hash; a reverse index maps fileId to jobId.
*
* put() atomicity: the hash fields, the per-job TTL, and the reverse-index entries are
- * issued inside a single pipelined Redis transaction (MULTI/EXEC). A partial failure cannot leave
- * the hash without a TTL or with half the file→job index entries written.
+ * issued inside a single Redis transaction (MULTI/EXEC). A partial failure cannot leave the hash
+ * without a TTL or with half the file->job index entries written.
*/
-@Component
-@RequiredArgsConstructor
+// TODO: Migration required - @ConditionalOnValkeyBackplane (Spring @ConditionalOnExpression) is a
+// runtime toggle on cluster.enabled + cluster.backplane=valkey. Quarkus has no direct equivalent
+// for the composite expression; either reimplement ConditionalOnValkeyBackplane as a Quarkus
+// build-time condition (@io.quarkus.arc.profile.IfBuildProfile /
+// @io.quarkus.arc.lookup.LookupIfProperty) or guard bean activation at runtime. Annotation left in
+// place pending that collaborator change.
+@ApplicationScoped
@ConditionalOnValkeyBackplane
@Slf4j
public class ValkeyJobStore implements JobStore {
@@ -47,7 +57,21 @@ public class ValkeyJobStore implements JobStore {
private static final TypeReference> LIST_STRING = new TypeReference<>() {};
private static final TypeReference> MAP_STRING = new TypeReference<>() {};
- private final StringRedisTemplate template;
+ // String-keyed, byte-valued command groups mirror the original byte-level access so JSON
+ // payloads and ids round-trip exactly as they did via StringRedisTemplate's byte commands.
+ private final RedisDataSource redis;
+ private final HashCommands hash;
+ private final ValueCommands value;
+ private final KeyCommands keys;
+ private final ValueCommands stringValue;
+
+ public ValkeyJobStore(RedisDataSource redis) {
+ this.redis = redis;
+ this.hash = redis.hash(String.class, String.class, byte[].class);
+ this.value = redis.value(String.class, byte[].class);
+ this.keys = redis.key(String.class);
+ this.stringValue = redis.value(String.class, String.class);
+ }
@Override
public void put(JobStoreEntry entry, Duration ttl) {
@@ -71,37 +95,30 @@ public class ValkeyJobStore implements JobStore {
"resultMeta",
writeJson(entry.resultMeta() == null ? Map.of() : entry.resultMeta()));
- // Build pipelined MULTI/EXEC so the hash, its TTL, and every reverse-index entry
- // commit atomically.
- template.execute(
- (RedisCallback)
- connection -> {
- connection.multi();
- byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
- Map hashBytes = new LinkedHashMap<>();
- for (Map.Entry f : fields.entrySet()) {
- hashBytes.put(
- f.getKey().getBytes(StandardCharsets.UTF_8),
- f.getValue().getBytes(StandardCharsets.UTF_8));
- }
- connection.hashCommands().hMSet(keyBytes, hashBytes);
- connection.keyCommands().pExpire(keyBytes, ttlMs);
- if (entry.fileIds() != null) {
- for (String fileId : entry.fileIds()) {
- byte[] idxKey =
- (FILE_INDEX_PREFIX + fileId)
- .getBytes(StandardCharsets.UTF_8);
- connection
- .stringCommands()
- .set(
- idxKey,
- entry.jobId().getBytes(StandardCharsets.UTF_8));
- connection.keyCommands().pExpire(idxKey, ttlMs);
- }
- }
- connection.exec();
- return null;
- });
+ Map hashBytes = new LinkedHashMap<>();
+ for (Map.Entry f : fields.entrySet()) {
+ hashBytes.put(f.getKey(), f.getValue().getBytes(StandardCharsets.UTF_8));
+ }
+
+ // Build MULTI/EXEC so the hash, its TTL, and every reverse-index entry commit atomically.
+ // Quarkus' withTransaction enqueues commands issued on the transactional datasource between
+ // MULTI and EXEC. SetArgs.px(ttl) sets the value-with-TTL in one SET (the original issued a
+ // separate pExpire after SET); pexpire keeps the original two-step shape for the hash.
+ redis.withTransaction(
+ tx -> {
+ tx.hash(String.class, String.class, byte[].class).hset(key, hashBytes);
+ tx.key(String.class).pexpire(key, ttlMs);
+ if (entry.fileIds() != null) {
+ for (String fileId : entry.fileIds()) {
+ String idxKey = FILE_INDEX_PREFIX + fileId;
+ tx.value(String.class, byte[].class)
+ .set(
+ idxKey,
+ entry.jobId().getBytes(StandardCharsets.UTF_8),
+ new SetArgs().px(ttlMs));
+ }
+ }
+ });
}
@Override
@@ -118,54 +135,40 @@ public class ValkeyJobStore implements JobStore {
// case; further contention falls through to lazy TTL cleanup (acceptable - this is an
// eviction path, not a correctness primitive).
String jobKey = JOB_PREFIX + jobId;
- byte[] jobKeyBytes = jobKey.getBytes(StandardCharsets.UTF_8);
for (int attempt = 0; attempt < 2; attempt++) {
- Boolean committed =
- template.execute(
- (RedisCallback)
- connection -> {
- connection.watch(jobKeyBytes);
- // Read the single fileIds field with hGet rather than
- // hGetAll + map.get: hGetAll returns a Map
- // whose keys compare by identity, so a fresh
- // "fileIds".getBytes() lookup never matches and the reverse
- // index would be left orphaned. hGet resolves the field
- // server-side.
- byte[] fileIdsBytes =
- connection
- .hashCommands()
- .hGet(
- jobKeyBytes,
- "fileIds"
- .getBytes(
- StandardCharsets
- .UTF_8));
- List keysToDelete = new ArrayList<>();
- keysToDelete.add(jobKeyBytes);
- if (fileIdsBytes != null) {
- List fileIds =
- readJsonList(
- new String(
- fileIdsBytes,
- StandardCharsets.UTF_8),
- jobKey);
- for (String fileId : fileIds) {
- keysToDelete.add(
- (FILE_INDEX_PREFIX + fileId)
- .getBytes(StandardCharsets.UTF_8));
- }
- }
- connection.multi();
- for (byte[] key : keysToDelete) {
- connection.keyCommands().del(key);
- }
- List results = connection.exec();
- // exec() returns null when WATCH detected a concurrent
- // write; spring-data-redis surfaces this as either null
- // or empty depending on the driver path.
- return results != null && !results.isEmpty();
- });
- if (Boolean.TRUE.equals(committed)) {
+ // withTransaction(preTxBlock, watchedKeys...): the preTxBlock runs after WATCH and
+ // before MULTI; its result feeds the transactional block. If a watched key changes
+ // before EXEC, Quarkus aborts and the result reports discarded() == true.
+ OptimisticLockingTransactionResult> result =
+ redis.withTransaction(
+ ds -> {
+ // Read the single fileIds field with hget rather than hgetall:
+ // resolve the field server-side and avoid byte[]-key identity
+ // pitfalls when looking it back up client-side.
+ byte[] fileIdsBytes =
+ ds.hash(String.class, String.class, byte[].class)
+ .hget(jobKey, "fileIds");
+ if (fileIdsBytes == null) {
+ return List.of();
+ }
+ return readJsonList(
+ new String(fileIdsBytes, StandardCharsets.UTF_8), jobKey);
+ },
+ tx -> {
+ List fileIds = tx.getPreTransactionResult();
+ List keysToDelete = new ArrayList<>();
+ keysToDelete.add(jobKey);
+ for (String fileId : fileIds) {
+ keysToDelete.add(FILE_INDEX_PREFIX + fileId);
+ }
+ tx.key(String.class)
+ .del(keysToDelete.toArray(new String[0]));
+ },
+ jobKey);
+ TransactionResult txResult = result.getExecutionResult();
+ // EXEC returns null (discarded) when WATCH detected a concurrent write; Quarkus
+ // surfaces this as discarded() == true.
+ if (txResult != null && !txResult.discarded()) {
return;
}
}
@@ -177,34 +180,40 @@ public class ValkeyJobStore implements JobStore {
@Override
public boolean exists(String jobId) {
- Boolean exists = template.hasKey(JOB_PREFIX + jobId);
- return Boolean.TRUE.equals(exists);
+ return keys.exists(JOB_PREFIX + jobId);
}
@Override
public Optional findJobIdByFileId(String fileId) {
- return Optional.ofNullable(template.opsForValue().get(FILE_INDEX_PREFIX + fileId));
+ return Optional.ofNullable(stringValue.get(FILE_INDEX_PREFIX + fileId));
}
@Override
public Collection all() {
// SCAN, not KEYS - KEYS blocks the Valkey server for the duration of the walk.
- ScanOptions options = ScanOptions.scanOptions().match(JOB_PREFIX + "*").count(256).build();
+ KeyScanCursor cursor =
+ keys.scan(new KeyScanArgs().match(JOB_PREFIX + "*").count(256));
List result = new ArrayList<>();
- try (Cursor cursor = template.scan(options)) {
- while (cursor.hasNext()) {
- readEntry(cursor.next()).ifPresent(result::add);
+ while (cursor.hasNext()) {
+ for (String key : cursor.next()) {
+ readEntry(key).ifPresent(result::add);
}
}
return result;
}
private Optional readEntry(String key) {
- Map entries = template.opsForHash().entries(key);
- if (entries == null || entries.isEmpty()) {
+ Map raw = hash.hgetall(key);
+ if (raw == null || raw.isEmpty()) {
return Optional.empty();
}
- Object jobId = entries.get("jobId");
+ Map entries = new HashMap<>();
+ for (Map.Entry e : raw.entrySet()) {
+ entries.put(
+ e.getKey(),
+ e.getValue() == null ? null : new String(e.getValue(), StandardCharsets.UTF_8));
+ }
+ String jobId = entries.get("jobId");
if (jobId == null) {
return Optional.empty();
}
@@ -223,10 +232,10 @@ public class ValkeyJobStore implements JobStore {
state = JobStoreEntry.JobState.PENDING;
}
String owningNodeId = String.valueOf(entries.getOrDefault("owningNodeId", ""));
- String error = entries.get("error") == null ? null : entries.get("error").toString();
+ String error = entries.get("error") == null ? null : entries.get("error");
return Optional.of(
new JobStoreEntry(
- jobId.toString(),
+ jobId,
state,
owningNodeId,
createdAt,
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyKeyValueCache.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyKeyValueCache.java
index 034189a3c1..cebb964fcf 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyKeyValueCache.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyKeyValueCache.java
@@ -4,54 +4,60 @@ import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
-import java.util.concurrent.TimeUnit;
-import org.springframework.data.redis.core.Cursor;
-import org.springframework.data.redis.core.ScanOptions;
-import org.springframework.data.redis.core.StringRedisTemplate;
-import org.springframework.stereotype.Component;
+import io.quarkus.redis.datasource.RedisDataSource;
+import io.quarkus.redis.datasource.keys.KeyScanArgs;
+import io.quarkus.redis.datasource.keys.KeyScanCursor;
+import io.quarkus.redis.datasource.value.SetArgs;
+import io.quarkus.redis.datasource.value.ValueCommands;
-import lombok.RequiredArgsConstructor;
+import jakarta.enterprise.context.ApplicationScoped;
import stirling.software.common.cluster.KeyValueCache;
-@Component
-@RequiredArgsConstructor
-@ConditionalOnValkeyBackplane
+// TODO: Migration required - @ConditionalOnValkeyBackplane (a Spring @ConditionalOnExpression
+// composite on cluster.enabled + cluster.backplane=valkey) has no direct CDI equivalent. Once that
+// collaborator annotation is migrated, re-guard this bean (e.g. @io.quarkus.arc.lookup.LookupIfProperty
+// or @io.quarkus.arc.profile.IfBuildProfile, or a runtime guard) so Valkey beans only load when
+// cluster.enabled=true AND cluster.backplane=valkey.
+@ApplicationScoped
public class ValkeyKeyValueCache implements KeyValueCache {
private static final String PREFIX = "stirling:kv:";
- private final StringRedisTemplate template;
+ private final RedisDataSource redis;
+ private final ValueCommands values;
+
+ public ValkeyKeyValueCache(RedisDataSource redis) {
+ this.redis = redis;
+ this.values = redis.value(String.class, String.class);
+ }
@Override
public void put(String namespace, String key, String value, Duration ttl) {
- template.opsForValue()
- .set(buildKey(namespace, key), value, ttl.toMillis(), TimeUnit.MILLISECONDS);
+ values.set(buildKey(namespace, key), value, new SetArgs().px(ttl.toMillis()));
}
@Override
public Optional get(String namespace, String key) {
- return Optional.ofNullable(template.opsForValue().get(buildKey(namespace, key)));
+ return Optional.ofNullable(values.get(buildKey(namespace, key)));
}
@Override
public void evict(String namespace, String key) {
- template.delete(buildKey(namespace, key));
+ redis.key(String.class).del(buildKey(namespace, key));
}
@Override
public void evictNamespace(String namespace) {
- ScanOptions options =
- ScanOptions.scanOptions().match(PREFIX + namespace + ":*").count(256).build();
+ KeyScanArgs options = new KeyScanArgs().match(PREFIX + namespace + ":*").count(256);
List keys = new ArrayList<>();
- try (Cursor cursor = template.scan(options)) {
- while (cursor.hasNext()) {
- keys.add(cursor.next());
- }
+ KeyScanCursor cursor = redis.key(String.class).scan(options);
+ while (cursor.hasNext()) {
+ keys.addAll(cursor.next());
}
if (!keys.isEmpty()) {
- template.delete(keys);
+ redis.key(String.class).del(keys.toArray(new String[0]));
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyRateLimitStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyRateLimitStore.java
index 0adda83c19..4a96aec88d 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyRateLimitStore.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyRateLimitStore.java
@@ -3,8 +3,13 @@ package stirling.software.proprietary.cluster.valkey;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
+// TODO: Migration required - LettuceConnectionFactory is a spring-data-redis type produced by the
+// not-yet-migrated ValkeyConnectionConfiguration collaborator. This store only needs the raw
+// io.lettuce.core.RedisClient that Bucket4j's Lettuce ProxyManager builds on. Once
+// ValkeyConnectionConfiguration is migrated to a Quarkus producer, switch this injection point to a
+// produced io.lettuce.core.RedisClient (or io.quarkus.redis.datasource.RedisDataSource) and delete
+// the getNativeClient() unwrap in initProxyManager(). Kept for now so the Bucket4j logic stays intact.
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
-import org.springframework.stereotype.Component;
import io.github.bucket4j.BucketConfiguration;
import io.github.bucket4j.ConsumptionProbe;
@@ -14,9 +19,12 @@ import io.github.bucket4j.distributed.proxy.ProxyManager;
import io.github.bucket4j.redis.lettuce.Bucket4jLettuce;
import io.lettuce.core.AbstractRedisClient;
import io.lettuce.core.RedisClient;
+import io.quarkus.arc.lookup.LookupIfProperty;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
import stirling.software.common.cluster.RateLimitStore;
@@ -25,8 +33,13 @@ import stirling.software.common.cluster.RateLimitStore;
* refills continuously and enforces one global limit across nodes, with the same semantics as the
* in-process {@code InProcessRateLimitStore} (which also uses Bucket4j).
*/
-@Component
+// @ConditionalOnValkeyBackplane is documentary only under CDI (see that annotation's javadoc);
+// the two guards below must be carried directly so the Valkey beans load only when
+// cluster.enabled=true AND cluster.backplane=valkey, otherwise the in-process @DefaultBean wins.
+@ApplicationScoped
@ConditionalOnValkeyBackplane
+@LookupIfProperty(name = "cluster.enabled", stringValue = "true")
+@LookupIfProperty(name = "cluster.backplane", stringValue = "valkey")
public class ValkeyRateLimitStore implements RateLimitStore {
private static final String PREFIX = "stirling:rl:";
@@ -34,6 +47,7 @@ public class ValkeyRateLimitStore implements RateLimitStore {
private final LettuceConnectionFactory connectionFactory;
private ProxyManager proxyManager;
+ @Inject
public ValkeyRateLimitStore(LettuceConnectionFactory connectionFactory) {
this.connectionFactory = connectionFactory;
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java
index ea096a8d23..fb8ec01209 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java
@@ -4,58 +4,60 @@ import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
-import org.slf4j.MDC;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.core.task.TaskDecorator;
-import org.springframework.core.task.support.TaskExecutorAdapter;
-import org.springframework.scheduling.annotation.EnableAsync;
-import org.springframework.security.concurrent.DelegatingSecurityContextExecutor;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Produces;
+import jakarta.inject.Named;
-@Configuration
-@EnableAsync
+import org.slf4j.MDC;
+
+@ApplicationScoped
public class AsyncConfig {
/**
- * MDC context-propagating task decorator. Copies MDC context from the caller thread to the
- * virtual thread executing the task.
+ * Wraps a delegate {@link Executor} so that the caller thread's MDC context is propagated to the
+ * worker (virtual) thread executing the task, then cleared afterwards to avoid leaks.
*/
- static class MDCContextTaskDecorator implements TaskDecorator {
- @Override
- public Runnable decorate(Runnable runnable) {
- // Capture the MDC context from the current thread
+ static Executor mdcPropagating(Executor delegate) {
+ return command -> {
+ // Capture the MDC context from the current (caller) thread
Map contextMap = MDC.getCopyOfContextMap();
- return () -> {
- try {
- // Set the captured context on the worker thread
- if (contextMap != null) {
- MDC.setContextMap(contextMap);
- }
- // Execute the task
- runnable.run();
- } finally {
- // Clear the context to prevent memory leaks
- MDC.clear();
- }
- };
- }
+ delegate.execute(
+ () -> {
+ try {
+ // Set the captured context on the worker thread
+ if (contextMap != null) {
+ MDC.setContextMap(contextMap);
+ }
+ // Execute the task
+ command.run();
+ } finally {
+ // Clear the context to prevent memory leaks
+ MDC.clear();
+ }
+ });
+ };
}
- @Bean(name = "auditExecutor")
+ @Produces
+ @Named("auditExecutor")
+ @ApplicationScoped
public Executor auditExecutor() {
- TaskExecutorAdapter adapter =
- new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
- adapter.setTaskDecorator(new MDCContextTaskDecorator());
- return adapter;
+ return mdcPropagating(Executors.newVirtualThreadPerTaskExecutor());
}
/** Propagates the request's SecurityContext onto background AI-orchestration threads. */
- @Bean(name = "aiStreamExecutor")
+ @Produces
+ @Named("aiStreamExecutor")
+ @ApplicationScoped
public Executor aiStreamExecutor() {
- TaskExecutorAdapter adapter =
- new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
- adapter.setTaskDecorator(new MDCContextTaskDecorator());
- return new DelegatingSecurityContextExecutor(adapter);
+ // TODO: Migration required - this previously wrapped the executor in Spring Security's
+ // DelegatingSecurityContextExecutor to propagate the SecurityContext onto background
+ // threads. Quarkus has no direct equivalent; the SecurityIdentity must be captured on the
+ // caller thread and re-established on the worker thread (e.g. via a captured
+ // io.quarkus.security.identity.SecurityIdentity or
+ // org.eclipse.microprofile.context.ThreadContext from MicroProfile Context Propagation).
+ // For now only MDC context is propagated; security context propagation is NOT preserved.
+ return mdcPropagating(Executors.newVirtualThreadPerTaskExecutor());
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java
index 366d91b11c..95cf86a1e4 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java
@@ -1,8 +1,7 @@
package stirling.software.proprietary.config;
-import org.springframework.core.Ordered;
-import org.springframework.core.annotation.Order;
-import org.springframework.stereotype.Component;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
@@ -14,10 +13,12 @@ import stirling.software.proprietary.audit.AuditLevel;
* Configuration properties for the audit system. Reads values from the ApplicationProperties under
* premium.enterpriseFeatures.audit
*/
+// TODO: Migration required - Spring @Order(HIGHEST_PRECEDENCE + 10) had no direct CDI
+// equivalent; bean ordering/precedence must be handled via @Priority or explicit ordering at
+// injection points if it was relied upon.
@Slf4j
@Getter
-@Component
-@Order(Ordered.HIGHEST_PRECEDENCE + 10)
+@ApplicationScoped
public class AuditConfigurationProperties {
private final boolean enabled;
@@ -27,6 +28,7 @@ public class AuditConfigurationProperties {
private final boolean capturePdfAuthor;
private final boolean captureOperationResults;
+ @Inject
public AuditConfigurationProperties(ApplicationProperties applicationProperties) {
ApplicationProperties.Premium.EnterpriseFeatures.Audit auditConfig =
applicationProperties.getPremium().getEnterpriseFeatures().getAudit();
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java
index 1ff8f4eb96..51bc58e2a5 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java
@@ -1,12 +1,12 @@
package stirling.software.proprietary.config;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.transaction.annotation.EnableTransactionManagement;
+import jakarta.enterprise.context.ApplicationScoped;
/** Configuration for audit system transaction management. */
-@Configuration
-@EnableTransactionManagement
-public class AuditJpaConfig {
- // Scheduling is enabled on SPDFApplication — no duplicate @EnableScheduling needed.
- // JPA repositories are now managed by DatabaseConfig to avoid conflicts.
-}
+// TODO: Migration required - Quarkus enables transaction management automatically
+// (Narayana/JTA via quarkus-narayana-jta); the Spring @EnableTransactionManagement is
+// not needed. Use jakarta.transaction.@Transactional on methods/beans as required.
+// Scheduling is enabled on the application — no duplicate @EnableScheduling needed.
+// JPA repositories are auto-discovered by Quarkus (no @EnableJpaRepositories needed).
+@ApplicationScoped
+public class AuditJpaConfig {}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java
index 1dd8d5f297..03addc2576 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java
@@ -4,13 +4,9 @@ import java.time.Instant;
import java.util.List;
import java.util.Map;
+import jakarta.enterprise.context.ApplicationScoped;
+
import org.slf4j.MDC;
-import org.springframework.boot.actuate.audit.AuditEvent;
-import org.springframework.boot.actuate.audit.AuditEventRepository;
-import org.springframework.context.annotation.Primary;
-import org.springframework.scheduling.annotation.Async;
-import org.springframework.stereotype.Component;
-import org.springframework.util.CollectionUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -21,30 +17,32 @@ import stirling.software.proprietary.util.SecretMasker;
import tools.jackson.databind.ObjectMapper;
-@Component
-@Primary
+// TODO: Migration required - this class implemented Spring Boot Actuator's
+// org.springframework.boot.actuate.audit.AuditEventRepository (with @Primary). Quarkus has no
+// Actuator equivalent, so the interface and the org.springframework.boot.actuate.audit.AuditEvent
+// type are gone. The write side has been ported to a plain CDI bean that accepts the audit data
+// directly (see add(...) below). Whatever Spring code previously published AuditEvents to this
+// repository must be updated to call this bean's add(...) method (or an equivalent producer) once
+// the audit-publishing pipeline is migrated. The read-side find(...) was intentionally inert
+// (endpoint disabled) and has been dropped.
+@ApplicationScoped
@RequiredArgsConstructor
@Slf4j
-public class CustomAuditEventRepository implements AuditEventRepository {
+public class CustomAuditEventRepository {
private final PersistentAuditEventRepository repo;
private final ObjectMapper mapper;
- /* ── READ side intentionally inert (endpoint disabled) ── */
- @Override
- public List find(String p, Instant after, String type) {
- return List.of();
- }
-
- /* ── WRITE side (async) ───────────────────────────────── */
- @Async("auditExecutor")
- @Override
- public void add(AuditEvent ev) {
+ /* ── WRITE side ───────────────────────────────────────── */
+ // TODO: Migration required - was @Async("auditExecutor") (Spring async executor). Quarkus has
+ // no @Async; run this off the request thread via a managed executor (e.g. inject
+ // org.eclipse.microprofile.context.ManagedExecutor and submit, or annotate with
+ // @io.smallrye.common.annotation.Blocking on a reactive path). Logic is kept synchronous for
+ // now to avoid changing behavior incorrectly.
+ public void add(String principal, String type, Instant timestamp, Map data) {
try {
Map clean =
- CollectionUtils.isEmpty(ev.getData())
- ? Map.of()
- : SecretMasker.mask(ev.getData());
+ (data == null || data.isEmpty()) ? Map.of() : SecretMasker.mask(data);
if (clean.isEmpty() || (clean.size() == 1 && clean.containsKey("details"))) {
return;
@@ -61,17 +59,24 @@ public class CustomAuditEventRepository implements AuditEventRepository {
PersistentAuditEvent ent =
PersistentAuditEvent.builder()
- .principal(ev.getPrincipal())
- .type(ev.getType())
+ .principal(principal)
+ .type(type)
.data(auditEventData)
- .timestamp(ev.getTimestamp())
+ .timestamp(timestamp)
.build();
+ // TODO: Migration required - repo.save(...) depends on PersistentAuditEventRepository
+ // being migrated to a Quarkus PanacheRepository (save -> persist). Update this call
+ // once that collaborator is converted.
repo.save(ent);
} catch (Exception e) {
- log.error(
- "Failed to persist audit event (fail-open); principal={}",
- ev.getPrincipal(),
- e);
+ log.error("Failed to persist audit event (fail-open); principal={}", principal, e);
}
}
+
+ /* ── READ side intentionally inert (endpoint disabled) ──
+ * Original find(String, Instant, String) returned List.of(); the Actuator read endpoint was
+ * disabled. Re-add a typed read method here if an audit-query endpoint is reintroduced. */
+ public List find() {
+ return List.of();
+ }
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/configuration/ServerCertificateInitializer.java b/app/proprietary/src/main/java/stirling/software/proprietary/configuration/ServerCertificateInitializer.java
index 6e82d1d994..11d4a6c7c6 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/configuration/ServerCertificateInitializer.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/configuration/ServerCertificateInitializer.java
@@ -1,23 +1,23 @@
package stirling.software.proprietary.configuration;
-import org.springframework.boot.context.event.ApplicationReadyEvent;
-import org.springframework.context.event.EventListener;
-import org.springframework.stereotype.Component;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.event.Observes;
+
+import io.quarkus.runtime.StartupEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.ServerCertificateServiceInterface;
-@Component
+@ApplicationScoped
@RequiredArgsConstructor
@Slf4j
public class ServerCertificateInitializer {
private final ServerCertificateServiceInterface serverCertificateService;
- @EventListener(ApplicationReadyEvent.class)
- public void initializeServerCertificate() {
+ public void initializeServerCertificate(@Observes StartupEvent event) {
try {
serverCertificateService.initializeServerCertificate();
} catch (Exception e) {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminJobController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminJobController.java
index ef941c603f..91567d81ae 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminJobController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminJobController.java
@@ -2,12 +2,12 @@ package stirling.software.proprietary.controller.api;
import java.util.Map;
-import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import jakarta.annotation.security.RolesAllowed;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.core.Response;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -23,11 +23,11 @@ import stirling.software.common.service.TaskManager;
* Admin controller for job management. These endpoints require admin privileges and provide insight
* into system jobs and queues.
*/
-@RestController
+@ApplicationScoped
+@Path("/api/v1/admin")
@RequiredArgsConstructor
@Slf4j
-@RequestMapping("/api/v1/admin")
-@PreAuthorize("hasRole('ADMIN')")
+@RolesAllowed("ADMIN")
@Tag(name = "Admin Job Management", description = "Admin-only Job Management APIs")
public class AdminJobController {
@@ -39,16 +39,17 @@ public class AdminJobController {
*
* @return Job statistics
*/
- @GetMapping("/job/stats")
+ @GET
+ @Path("/job/stats")
@Operation(summary = "Get job statistics")
- @PreAuthorize("hasRole('ADMIN')")
- public ResponseEntity getJobStats() {
+ @RolesAllowed("ADMIN")
+ public Response getJobStats() {
JobStats stats = taskManager.getJobStats();
log.info(
"Admin requested job stats: {} active, {} completed jobs",
stats.getActiveJobs(),
stats.getCompletedJobs());
- return ResponseEntity.ok(stats);
+ return Response.ok(stats).build();
}
/**
@@ -56,13 +57,14 @@ public class AdminJobController {
*
* @return Queue statistics
*/
- @GetMapping("/job/queue/stats")
+ @GET
+ @Path("/job/queue/stats")
@Operation(summary = "Get job queue statistics")
- @PreAuthorize("hasRole('ADMIN')")
- public ResponseEntity> getQueueStats() {
+ @RolesAllowed("ADMIN")
+ public Response getQueueStats() {
Map queueStats = jobQueue.getQueueStats();
log.info("Admin requested queue stats: {} queued jobs", queueStats.get("queuedJobs"));
- return ResponseEntity.ok(queueStats);
+ return Response.ok(queueStats).build();
}
/**
@@ -70,10 +72,11 @@ public class AdminJobController {
*
* @return A response indicating how many jobs were cleaned up
*/
- @PostMapping("/job/cleanup")
+ @POST
+ @Path("/job/cleanup")
@Operation(summary = "Cleanup old jobs")
- @PreAuthorize("hasRole('ADMIN')")
- public ResponseEntity> cleanupOldJobs() {
+ @RolesAllowed("ADMIN")
+ public Response cleanupOldJobs() {
int beforeCount = taskManager.getJobStats().getTotalJobs();
taskManager.cleanupOldJobs();
int afterCount = taskManager.getJobStats().getTotalJobs();
@@ -84,10 +87,11 @@ public class AdminJobController {
removedCount,
afterCount);
- return ResponseEntity.ok(
- Map.of(
- "message", "Cleanup complete",
- "removedJobs", removedCount,
- "remainingJobs", afterCount));
+ return Response.ok(
+ Map.of(
+ "message", "Cleanup complete",
+ "removedJobs", removedCount,
+ "remainingJobs", afterCount))
+ .build();
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AiEngineController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AiEngineController.java
index a96b3d1240..d57837a0bb 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AiEngineController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AiEngineController.java
@@ -5,26 +5,28 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Qualifier;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.ModelAttribute;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
-import org.springframework.web.server.ResponseStatusException;
-import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Instance;
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
import jakarta.validation.Valid;
+import jakarta.ws.rs.BeanParam;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.WebApplicationException;
+import jakarta.ws.rs.core.Context;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.sse.OutboundSseEvent;
+import jakarta.ws.rs.sse.Sse;
+import jakarta.ws.rs.sse.SseEventSink;
import lombok.extern.slf4j.Slf4j;
@@ -47,8 +49,8 @@ import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
@Slf4j
-@RestController
-@RequestMapping("/api/v1/ai")
+@ApplicationScoped
+@jakarta.ws.rs.Path("/api/v1/ai")
@Hidden
@Tag(name = "AI Engine", description = "Endpoints for AI-powered PDF workflows")
public class AiEngineController {
@@ -60,25 +62,31 @@ public class AiEngineController {
private final TaskManager taskManager;
private final JobOwnershipService jobOwnershipService;
private final AiEngineEndpointResolver endpointResolver;
- private final UserServiceInterface userService;
+ private final Instance userService;
/**
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
* 1000-page scan, splitting a huge PDF, etc.) without the emitter completing out from under the
* executor. Configurable via {@code stirling.ai.streamTimeoutMs}.
+ *
+ * TODO: Migration required - the JAX-RS SSE API has no per-emitter timeout equivalent to
+ * Spring's {@code SseEmitter} constructor argument. Enforce this timeout against the background
+ * orchestration task (e.g. a scheduled cancellation / Future.get with timeout) if a hard cap is
+ * required; for now it only drives the timeout error frame's wording.
*/
- @Value("${stirling.ai.streamTimeoutMs:1800000}")
- private long streamTimeoutMs;
+ @ConfigProperty(name = "stirling.ai.streamTimeoutMs", defaultValue = "1800000")
+ long streamTimeoutMs;
+ @Inject
public AiEngineController(
AiEngineClient aiEngineClient,
AiWorkflowService aiWorkflowService,
ObjectMapper objectMapper,
- @Qualifier("aiStreamExecutor") Executor aiStreamExecutor,
+ @Named("aiStreamExecutor") Executor aiStreamExecutor,
TaskManager taskManager,
JobOwnershipService jobOwnershipService,
AiEngineEndpointResolver endpointResolver,
- @Autowired(required = false) UserServiceInterface userService) {
+ Instance userService) {
this.aiEngineClient = aiEngineClient;
this.aiWorkflowService = aiWorkflowService;
this.objectMapper = objectMapper;
@@ -90,71 +98,70 @@ public class AiEngineController {
}
private String currentUserId() {
- return userService != null ? userService.getCurrentUsername() : null;
+ return userService.isResolvable() ? userService.get().getCurrentUsername() : null;
}
- @GetMapping("/health")
+ @GET
+ @jakarta.ws.rs.Path("/health")
@Operation(
summary = "AI engine health check",
description = "Returns the health status of the AI engine including configured models")
- public ResponseEntity health() throws IOException {
+ public Response health() throws IOException {
String response = aiEngineClient.get("/health", currentUserId());
- return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
+ return Response.ok(response, MediaType.APPLICATION_JSON).build();
}
- @PostMapping(value = "/orchestrate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @POST
+ @jakarta.ws.rs.Path("/orchestrate")
+ @Consumes(MediaType.MULTIPART_FORM_DATA)
@Operation(
summary = "Run an AI workflow against a PDF",
description =
"Accepts PDF uploads and a user message and returns an AI workflow result."
+ " When the workflow produces files, they are registered with the job"
+ " system and downloadable via GET /api/v1/general/files/{fileId}.")
- public AiWorkflowResponse orchestrate(@Valid @ModelAttribute AiWorkflowRequest request)
+ // TODO: Migration required - @BeanParam multipart binding depends on collaborator changes:
+ // AiWorkflowRequest / AiWorkflowFileInput must have their multipart fields annotated with
+ // @org.jboss.resteasy.reactive.RestForm and the nested AiWorkflowFileInput.fileInput must be
+ // ported off Spring's MultipartFile to FileUpload + FileUploadMultipartFile.of(...). Until then
+ // RESTEasy Reactive cannot populate this request from the multipart form body.
+ public AiWorkflowResponse orchestrate(@Valid @BeanParam AiWorkflowRequest request)
throws IOException {
AiWorkflowResponse result = aiWorkflowService.orchestrate(request);
registerFileResultAsJob(result);
return result;
}
- @PostMapping(value = "/orchestrate/stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @POST
+ @jakarta.ws.rs.Path("/orchestrate/stream")
+ @Consumes(MediaType.MULTIPART_FORM_DATA)
+ @org.jboss.resteasy.reactive.RestStreamElementType(MediaType.APPLICATION_JSON)
@Operation(
summary = "Run an AI workflow with streaming progress",
description =
"Accepts a PDF upload and a user message, returns SSE events with progress"
+ " updates followed by the final AI workflow result")
- public SseEmitter orchestrateStream(@Valid @ModelAttribute AiWorkflowRequest request) {
- SseEmitter emitter = new SseEmitter(streamTimeoutMs);
-
- emitter.onTimeout(
- () -> {
- // Emit an explicit error frame so the frontend reports a timeout rather than
- // silently seeing the stream end without a result.
- log.warn(
- "SSE emitter timed out for AI orchestration stream after {} ms",
- streamTimeoutMs);
- sendEvent(
- emitter,
- "error",
- Map.of(
- "message",
- "AI workflow timed out after "
- + (streamTimeoutMs / 1000)
- + " seconds"));
- emitter.complete();
- });
- emitter.onError(e -> log.warn("SSE emitter error for AI orchestration stream", e));
-
- aiStreamExecutor.execute(() -> runOrchestrationStream(request, emitter));
-
- return emitter;
+ // TODO: Migration required - same @BeanParam multipart binding dependency as orchestrate():
+ // AiWorkflowRequest / AiWorkflowFileInput need @RestForm fields and a FileUpload-based file
+ // model before RESTEasy Reactive can bind this request from the multipart body.
+ public void orchestrateStream(
+ @Valid @BeanParam AiWorkflowRequest request,
+ @Context Sse sse,
+ @Context SseEventSink sink) {
+ // The JAX-RS SseEventSink replaces Spring's SseEmitter. There is no onTimeout/onError
+ // callback registration; sink.send(...) returns a CompletionStage and a disconnected
+ // client surfaces as a failed send / closed sink, which the orchestration loop detects
+ // via ClientDisconnectedException below.
+ aiStreamExecutor.execute(() -> runOrchestrationStream(request, sse, sink));
}
- private void runOrchestrationStream(AiWorkflowRequest request, SseEmitter emitter) {
+ private void runOrchestrationStream(
+ AiWorkflowRequest request, Sse sse, SseEventSink sink) {
AiWorkflowService.ProgressListener listener =
new AiWorkflowService.ProgressListener() {
@Override
public void onProgress(AiWorkflowProgressEvent event) {
- sendEvent(emitter, "progress", event);
+ sendEvent(sse, sink, "progress", event);
}
@Override
@@ -163,26 +170,27 @@ public class AiEngineController {
// real progress events; if the frontend has gone away, sendEvent throws,
// which propagates up through the stream consumer and closes our upstream
// engine connection so the engine can cancel its in-flight workflow.
- sendEvent(emitter, "heartbeat", Map.of());
+ sendEvent(sse, sink, "heartbeat", Map.of());
}
};
try {
AiWorkflowResponse result = aiWorkflowService.orchestrate(request, listener);
registerFileResultAsJob(result);
- sendEvent(emitter, "result", result);
- emitter.complete();
+ sendEvent(sse, sink, "result", result);
+ sink.close();
} catch (ClientDisconnectedException e) {
// The frontend gave up mid-stream. The exception unwinding through orchestrate()
// already closed the upstream engine connection (engine sees disconnect and cancels).
- // The emitter is already toast; nothing useful left to send.
+ // The sink is already toast; nothing useful left to send.
log.debug("Client disconnected mid-stream; aborting workflow", e);
} catch (Exception e) {
log.error("AI orchestration stream failed", e);
- // Emit an error frame for the frontend and then complete normally. Using
- // completeWithError here as well would double-complete the emitter - the error
+ // Emit an error frame for the frontend and then complete normally. The error
// frame already conveys the failure to the client.
- sendEvent(emitter, "error", Map.of("message", e.getMessage()));
- emitter.complete();
+ sendEvent(sse, sink, "error", Map.of("message", e.getMessage()));
+ if (!sink.isClosed()) {
+ sink.close();
+ }
}
}
@@ -217,21 +225,30 @@ public class AiEngineController {
taskManager.setComplete(jobKey);
}
- private void sendEvent(SseEmitter emitter, String name, Object data) {
+ private void sendEvent(Sse sse, SseEventSink sink, String name, Object data) {
+ if (sink.isClosed()) {
+ throw new ClientDisconnectedException("Client disconnected from SSE stream", null);
+ }
+ OutboundSseEvent event =
+ sse.newEventBuilder()
+ .name(name)
+ .mediaType(MediaType.APPLICATION_JSON_TYPE)
+ .data(data)
+ .build();
try {
- emitter.send(SseEmitter.event().name(name).data(data, MediaType.APPLICATION_JSON));
- } catch (IOException e) {
- // Surface the disconnect so the streaming pipeline unwinds: callers higher up close
- // the upstream engine connection, which lets the engine cancel its in-flight workflow.
- // Without this, the engine would keep producing (and billing for) tokens whose results
- // nobody is reading.
+ // CompletionStage join surfaces a delivery failure (client gone) synchronously so the
+ // streaming pipeline unwinds: callers higher up close the upstream engine connection,
+ // which lets the engine cancel its in-flight workflow. Without this, the engine would
+ // keep producing (and billing for) tokens whose results nobody is reading.
+ sink.send(event).toCompletableFuture().join();
+ } catch (RuntimeException e) {
throw new ClientDisconnectedException("Client disconnected from SSE stream", e);
}
}
/**
- * Thrown by {@link #sendEvent} when the SSE emitter's underlying connection is gone. Treated as
- * a signal to abort the workflow, not as an error to report.
+ * Thrown by {@link #sendEvent} when the SSE sink's underlying connection is gone. Treated as a
+ * signal to abort the workflow, not as an error to report.
*/
private static final class ClientDisconnectedException extends RuntimeException {
ClientDisconnectedException(String message, Throwable cause) {
@@ -239,29 +256,31 @@ public class AiEngineController {
}
}
- @PostMapping(value = "/pdf/edit", consumes = MediaType.APPLICATION_JSON_VALUE)
+ @POST
+ @jakarta.ws.rs.Path("/pdf/edit")
+ @Consumes(MediaType.APPLICATION_JSON)
@Operation(
summary = "Generate a PDF edit plan",
description =
"Sends a user message to the PDF edit agent which returns a structured plan"
+ " of tool operations to perform")
- public ResponseEntity pdfEdit(@RequestBody String requestBody) throws IOException {
+ public Response pdfEdit(String requestBody) throws IOException {
JsonNode parsed = parseJson(requestBody);
if (!parsed.isObject()) {
- throw new ResponseStatusException(
- HttpStatus.BAD_REQUEST, "Request body must be a JSON object");
+ throw new WebApplicationException(
+ "Request body must be a JSON object", Response.Status.BAD_REQUEST);
}
String forwardedBody = withEnabledEndpoints((ObjectNode) parsed);
String response = aiEngineClient.post("/api/v1/pdf/edit", forwardedBody, currentUserId());
- return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
+ return Response.ok(response, MediaType.APPLICATION_JSON).build();
}
private JsonNode parseJson(String body) {
try {
return objectMapper.readValue(body, JsonNode.class);
} catch (JacksonException e) {
- throw new ResponseStatusException(
- HttpStatus.BAD_REQUEST, "Request body is not valid JSON");
+ throw new WebApplicationException(
+ "Request body is not valid JSON", Response.Status.BAD_REQUEST);
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java
index 837c28bf5a..582e520312 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java
@@ -13,21 +13,17 @@ import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
-import org.springdoc.core.annotations.ParameterObject;
-import org.springframework.data.domain.Page;
-import org.springframework.data.domain.PageRequest;
-import org.springframework.data.domain.Pageable;
-import org.springframework.data.domain.Sort;
-import org.springframework.format.annotation.DateTimeFormat;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.MediaType;
-import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.DeleteMapping;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
+import jakarta.annotation.security.RolesAllowed;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.ws.rs.BeanParam;
+import jakarta.ws.rs.DELETE;
+import jakarta.ws.rs.DefaultValue;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -36,6 +32,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import io.quarkus.panache.common.Page;
+import io.quarkus.panache.common.Sort;
+
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.model.api.audit.AuditDataRequest;
import stirling.software.proprietary.model.api.audit.AuditDataResponse;
@@ -50,9 +49,9 @@ import tools.jackson.databind.ObjectMapper;
/** REST endpoints for the audit dashboard. */
@Slf4j
-@RestController
-@RequestMapping("/api/v1/audit")
-@PreAuthorize("hasRole('ADMIN')")
+@ApplicationScoped
+@jakarta.ws.rs.Path("/api/v1/audit")
+@RolesAllowed("ADMIN")
@RequiredArgsConstructor
@EnterpriseEndpoint
@Tag(name = "Audit", description = "Only Enterprise - Audit related operations")
@@ -62,14 +61,20 @@ public class AuditDashboardController {
private final ObjectMapper objectMapper;
/** Get audit events data for the dashboard tables. */
- @GetMapping("/data")
+ @GET
+ @jakarta.ws.rs.Path("/data")
@Operation(summary = "Get audit events data")
- public AuditDataResponse getAuditData(@ParameterObject AuditDataRequest request) {
+ public AuditDataResponse getAuditData(@BeanParam AuditDataRequest request) {
- Pageable pageable =
- PageRequest.of(
- request.getPage(), request.getSize(), Sort.by("timestamp").descending());
- Page events;
+ // TODO: Migration required - PersistentAuditEventRepository is a collaborator that must be
+ // migrated to io.quarkus.hibernate.orm.panache.PanacheRepositoryBase. Its paged finders should return io.quarkus.panache.common.PanacheQuery (or apply
+ // the Page/Sort built here) instead of org.springframework.data.domain.Page. The pagination
+ // request below is expressed with Panache Page/Sort; once the repository accepts these the
+ // .page(...)/.list()/.count()/.pageCount() calls used here will resolve.
+ Page page = Page.of(request.getPage(), request.getSize());
+ Sort sort = Sort.by("timestamp", Sort.Direction.Descending);
+ io.quarkus.hibernate.orm.panache.PanacheQuery query;
String type = request.getType();
String principal = request.getPrincipal();
@@ -79,49 +84,52 @@ public class AuditDashboardController {
if (type != null && principal != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
- events =
+ query =
auditRepository.findByPrincipalAndTypeAndTimestampBetween(
- principal, type, start, end, pageable);
+ principal, type, start, end, page, sort);
} else if (type != null && principal != null) {
- events = auditRepository.findByPrincipalAndType(principal, type, pageable);
+ query = auditRepository.findByPrincipalAndType(principal, type, page, sort);
} else if (type != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
- events = auditRepository.findByTypeAndTimestampBetween(type, start, end, pageable);
+ query = auditRepository.findByTypeAndTimestampBetween(type, start, end, page, sort);
} else if (principal != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
- events =
+ query =
auditRepository.findByPrincipalAndTimestampBetween(
- principal, start, end, pageable);
+ principal, start, end, page, sort);
} else if (startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
- events = auditRepository.findByTimestampBetween(start, end, pageable);
+ query = auditRepository.findByTimestampBetween(start, end, page, sort);
} else if (type != null) {
- events = auditRepository.findByType(type, pageable);
+ query = auditRepository.findByType(type, page, sort);
} else if (principal != null) {
- events = auditRepository.findByPrincipal(principal, pageable);
+ query = auditRepository.findByPrincipal(principal, page, sort);
} else {
- events = auditRepository.findAll(pageable);
+ query = auditRepository.findAll(sort).page(page);
}
// Logging
- List content = events.getContent();
+ List content = query.list();
return new AuditDataResponse(
- content, events.getTotalPages(), events.getTotalElements(), events.getNumber());
+ content, query.pageCount(), query.count(), query.page().index);
}
/** Get statistics for charts (last X days). Existing behavior preserved. */
- @GetMapping("/stats")
+ @GET
+ @jakarta.ws.rs.Path("/stats")
+ @Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Get audit statistics for the last N days")
public AuditStatsResponse getAuditStats(
@Schema(
description = "Number of days to look back for audit events",
example = "7",
requiredMode = Schema.RequiredMode.REQUIRED)
- @RequestParam(value = "days", defaultValue = "7")
+ @QueryParam("days")
+ @DefaultValue("7")
int days) {
// Get events from the last X days
@@ -158,9 +166,10 @@ public class AuditDashboardController {
}
// /** Advanced statistics using repository aggregations, with explicit date range. */
- // @GetMapping("/stats/range")
+ // @GET
+ // @Path("/stats/range")
// @Operation(summary = "Get audit statistics for a date range (aggregated in DB)")
- // public Map getAuditStatsRange(@ParameterObject AuditDateExportRequest
+ // public Map getAuditStatsRange(@BeanParam AuditDateExportRequest
// request) {
// LocalDate startDate = request.getStartDate();
@@ -199,7 +208,9 @@ public class AuditDashboardController {
// }
/** Get all unique event types from the database for filtering. */
- @GetMapping("/types")
+ @GET
+ @jakarta.ws.rs.Path("/types")
+ @Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Get all unique audit event types")
public List getAuditTypes() {
// Get distinct event types from the database
@@ -220,9 +231,10 @@ public class AuditDashboardController {
}
/** Export audit data as CSV. */
- @GetMapping("/export/csv")
+ @GET
+ @jakarta.ws.rs.Path("/export/csv")
@Operation(summary = "Export audit data as CSV")
- public ResponseEntity exportAuditData(@ParameterObject AuditExportRequest request) {
+ public Response exportAuditData(@BeanParam AuditExportRequest request) {
List events = getAuditEventsByCriteria(request);
@@ -243,17 +255,19 @@ public class AuditDashboardController {
byte[] csvBytes = csv.toString().getBytes(StandardCharsets.UTF_8);
// Set up HTTP headers for download
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
- headers.setContentDispositionFormData("attachment", "audit_export.csv");
-
- return ResponseEntity.ok().headers(headers).body(csvBytes);
+ return Response.ok(csvBytes)
+ .type(MediaType.APPLICATION_OCTET_STREAM)
+ .header(
+ HttpHeaders.CONTENT_DISPOSITION,
+ "form-data; name=\"attachment\"; filename=\"audit_export.csv\"")
+ .build();
}
/** Export audit data as JSON. */
- @GetMapping("/export/json")
+ @GET
+ @jakarta.ws.rs.Path("/export/json")
@Operation(summary = "Export audit data as JSON")
- public ResponseEntity exportAuditDataJson(@ParameterObject AuditExportRequest request) {
+ public Response exportAuditDataJson(@BeanParam AuditExportRequest request) {
List events = getAuditEventsByCriteria(request);
@@ -262,66 +276,71 @@ public class AuditDashboardController {
byte[] jsonBytes = objectMapper.writeValueAsBytes(events);
// Set up HTTP headers for download
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.setContentDispositionFormData("attachment", "audit_export.json");
-
- return ResponseEntity.ok().headers(headers).body(jsonBytes);
+ return Response.ok(jsonBytes)
+ .type(MediaType.APPLICATION_JSON)
+ .header(
+ HttpHeaders.CONTENT_DISPOSITION,
+ "form-data; name=\"attachment\"; filename=\"audit_export.json\"")
+ .build();
} catch (JacksonException e) {
log.error("Error serializing audit events to JSON", e);
- return ResponseEntity.internalServerError().build();
+ return Response.serverError().build();
}
}
// /** Get all unique principals. */
- // @GetMapping("/principals")
+ // @GET
+ // @Path("/principals")
// @Operation(summary = "Get all distinct principals")
// public List getPrincipals() {
// return auditRepository.findDistinctPrincipals();
// }
// /** Get principals by event type. */
- // @GetMapping("/types/{type}/principals")
+ // @GET
+ // @Path("/types/{type}/principals")
// @Operation(summary = "Get distinct principals for a given type")
- // public List getPrincipalsByType(@PathVariable("type") String type) {
+ // public List getPrincipalsByType(@PathParam("type") String type) {
// return auditRepository.findDistinctPrincipalsByType(type);
// }
// /** Latest helpers */
- // @GetMapping("/latest")
+ // @GET
+ // @Path("/latest")
// @Operation(summary = "Get the latest audit event, optionally filtered by type or principal")
- // public ResponseEntity getLatest(
- // @RequestParam(value = "type", required = false) String type,
- // @RequestParam(value = "principal", required = false) String principal) {
+ // public Response getLatest(
+ // @QueryParam("type") String type,
+ // @QueryParam("principal") String principal) {
// if (type != null) {
// return auditRepository
// .findTopByTypeOrderByTimestampDesc(type)
- // .map(ResponseEntity::ok)
- // .orElse(ResponseEntity.noContent().build());
+ // .map(e -> Response.ok(e).build())
+ // .orElse(Response.noContent().build());
// } else if (principal != null) {
// return auditRepository
// .findTopByPrincipalOrderByTimestampDesc(principal)
- // .map(ResponseEntity::ok)
- // .orElse(ResponseEntity.noContent().build());
+ // .map(e -> Response.ok(e).build())
+ // .orElse(Response.noContent().build());
// }
// return auditRepository
// .findTopByOrderByTimestampDesc()
- // .map(ResponseEntity::ok)
- // .orElse(ResponseEntity.noContent().build());
+ // .map(e -> Response.ok(e).build())
+ // .orElse(Response.noContent().build());
// }
/** Cleanup endpoints data before a certain date */
- @DeleteMapping("/cleanup/before")
+ @DELETE
+ @jakarta.ws.rs.Path("/cleanup/before")
+ @Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Cleanup audit events before a certain date",
description = "Deletes all audit events before the specified date.")
public Map cleanupBefore(
- @RequestParam(value = "date", required = true)
+ @QueryParam("date")
@Schema(
description = "The cutoff date for cleanup",
example = "2025-01-01",
format = "date")
- @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate date) {
if (date != null && !date.isAfter(LocalDate.now())) {
Instant cutoff = date.atStartOfDay(ZoneId.systemDefault()).toInstant();
@@ -390,7 +409,7 @@ public class AuditDashboardController {
} else if (principal != null) {
events = auditRepository.findAllByPrincipalForExport(principal);
} else {
- events = auditRepository.findAll();
+ events = auditRepository.listAll();
}
return events;
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java
index a208a46a81..adb0aba3b0 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java
@@ -9,18 +9,25 @@ import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
+import jakarta.annotation.security.RolesAllowed;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+// TODO: Migration required - PersistentAuditEventRepository is a not-yet-migrated collaborator
+// (Spring Data JPA, task: Code: Spring Data JPA -> Hibernate ORM Panache). It still returns Spring
+// org.springframework.data.domain.Page and accepts Pageable. These four Spring Data imports must
+// stay until that repository is ported to PanacheRepositoryBase. Once it is, replace Pageable with
+// io.quarkus.panache.common.Page, Sort.by("timestamp").descending() with
+// io.quarkus.panache.common.Sort.descending("timestamp"), and Page<...> with PanacheQuery<...>.
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
-import org.springframework.format.annotation.DateTimeFormat;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.MediaType;
-import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestParam;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -36,8 +43,12 @@ import tools.jackson.databind.ObjectMapper;
/** REST API controller for audit data used by React frontend. */
@Slf4j
+@ApplicationScoped
+// @ProprietaryUiDataApi carries only the OpenAPI @Tag; JAX-RS does not inherit @Path from
+// meta-annotations, so the path is declared explicitly here.
+@jakarta.ws.rs.Path("/api/v1/proprietary/ui-data")
@ProprietaryUiDataApi
-@PreAuthorize("hasRole('ADMIN')")
+@RolesAllowed("ADMIN")
@RequiredArgsConstructor
@EnterpriseEndpoint
public class AuditRestController {
@@ -51,33 +62,32 @@ public class AuditRestController {
*
* @param page Page number (0-indexed)
* @param pageSize Number of items per page
- * @param eventType Filter by event type(s) - can be single value or array
- * @param username Filter by username(s) - can be single value or array
- * @param startDate Filter start date
- * @param endDate Filter end date
+ * @param eventTypes Filter by event type(s) - can be single value or array
+ * @param usernames Filter by username(s) - can be single value or array
+ * @param startDateStr Filter start date (ISO yyyy-MM-dd)
+ * @param endDateStr Filter end date (ISO yyyy-MM-dd)
* @return Paginated audit events response
*/
- @GetMapping("/audit-events")
- public ResponseEntity getAuditEvents(
- @RequestParam(value = "page", defaultValue = "0") int page,
- @RequestParam(value = "pageSize", defaultValue = "30") int pageSize,
- @RequestParam(value = "eventType", required = false) String[] eventTypes,
- @RequestParam(value = "username", required = false) String[] usernames,
- @RequestParam(value = "startDate", required = false)
- @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
- LocalDate startDate,
- @RequestParam(value = "endDate", required = false)
- @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
- LocalDate endDate) {
+ @GET
+ @jakarta.ws.rs.Path("/audit-events")
+ public Response getAuditEvents(
+ @QueryParam("page") @jakarta.ws.rs.DefaultValue("0") int page,
+ @QueryParam("pageSize") @jakarta.ws.rs.DefaultValue("30") int pageSize,
+ @QueryParam("eventType") List eventTypes,
+ @QueryParam("username") List usernames,
+ @QueryParam("startDate") String startDateStr,
+ @QueryParam("endDate") String endDateStr) {
+
+ LocalDate startDate = parseIsoDate(startDateStr);
+ LocalDate endDate = parseIsoDate(endDateStr);
Pageable pageable = PageRequest.of(page, pageSize, Sort.by("timestamp").descending());
Page events;
// Convert arrays to lists
List eventTypeList =
- (eventTypes != null && eventTypes.length > 0) ? Arrays.asList(eventTypes) : null;
- List usernameList =
- (usernames != null && usernames.length > 0) ? Arrays.asList(usernames) : null;
+ (eventTypes != null && !eventTypes.isEmpty()) ? eventTypes : null;
+ List usernameList = (usernames != null && !usernames.isEmpty()) ? usernames : null;
Instant startInstant = null;
Instant endInstant = null;
@@ -129,7 +139,7 @@ public class AuditRestController {
.totalPages(events.getTotalPages())
.build();
- return ResponseEntity.ok(response);
+ return Response.ok(response).build();
}
/**
@@ -138,9 +148,10 @@ public class AuditRestController {
* @param period Time period for charts (day/week/month)
* @return Chart data for events by type, user, and over time
*/
- @GetMapping("/audit-charts")
- public ResponseEntity getAuditCharts(
- @RequestParam(value = "period", defaultValue = "week") String period) {
+ @GET
+ @jakarta.ws.rs.Path("/audit-charts")
+ public Response getAuditCharts(
+ @QueryParam("period") @jakarta.ws.rs.DefaultValue("week") String period) {
// Calculate days based on period
int days;
@@ -224,7 +235,7 @@ public class AuditRestController {
.eventsOverTime(eventsOverTimeChart)
.build();
- return ResponseEntity.ok(chartsData);
+ return Response.ok(chartsData).build();
}
/**
@@ -232,8 +243,9 @@ public class AuditRestController {
*
* @return List of unique event types
*/
- @GetMapping("/audit-event-types")
- public ResponseEntity> getEventTypes() {
+ @GET
+ @jakarta.ws.rs.Path("/audit-event-types")
+ public Response getEventTypes() {
// Get distinct event types from the database
List dbTypes = auditRepository.findDistinctEventTypes();
@@ -250,7 +262,7 @@ public class AuditRestController {
List result = combinedTypes.stream().sorted().collect(Collectors.toList());
- return ResponseEntity.ok(result);
+ return Response.ok(result).build();
}
/**
@@ -258,8 +270,9 @@ public class AuditRestController {
*
* @return List of unique usernames
*/
- @GetMapping("/audit-users")
- public ResponseEntity> getUsers() {
+ @GET
+ @jakarta.ws.rs.Path("/audit-users")
+ public Response getUsers() {
// Use the countByPrincipal query to get unique principals
List principalCounts = auditRepository.countByPrincipal();
@@ -269,7 +282,7 @@ public class AuditRestController {
.sorted()
.collect(Collectors.toList());
- return ResponseEntity.ok(users);
+ return Response.ok(users).build();
}
/**
@@ -279,9 +292,10 @@ public class AuditRestController {
* @param period Time period for statistics (day/week/month)
* @return Audit statistics data for dashboard KPI cards and enhanced charts
*/
- @GetMapping("/audit-stats")
- public ResponseEntity getAuditStats(
- @RequestParam(value = "period", defaultValue = "week") String period) {
+ @GET
+ @jakarta.ws.rs.Path("/audit-stats")
+ public Response getAuditStats(
+ @QueryParam("period") @jakarta.ws.rs.DefaultValue("week") String period) {
// Calculate days based on period
int days;
@@ -323,24 +337,25 @@ public class AuditRestController {
hourlyDistribution.put(String.format("%02d", hour), count);
}
- return ResponseEntity.ok(
- AuditStatsData.builder()
- .totalEvents(currentMetrics.totalEvents)
- .prevTotalEvents(prevMetrics.totalEvents)
- .uniqueUsers(currentMetrics.uniqueUsers)
- .prevUniqueUsers(prevMetrics.uniqueUsers)
- .successRate(currentMetrics.successRate)
- .prevSuccessRate(prevMetrics.successRate)
- .avgLatencyMs(currentMetrics.avgLatencyMs)
- .prevAvgLatencyMs(prevMetrics.avgLatencyMs)
- .errorCount(currentMetrics.errorCount)
- .topEventType(currentMetrics.topEventType)
- .topUser(currentMetrics.topUser)
- .eventsByType(currentMetrics.eventsByType)
- .eventsByUser(currentMetrics.eventsByUser)
- .topTools(currentMetrics.topTools)
- .hourlyDistribution(hourlyDistribution)
- .build());
+ return Response.ok(
+ AuditStatsData.builder()
+ .totalEvents(currentMetrics.totalEvents)
+ .prevTotalEvents(prevMetrics.totalEvents)
+ .uniqueUsers(currentMetrics.uniqueUsers)
+ .prevUniqueUsers(prevMetrics.uniqueUsers)
+ .successRate(currentMetrics.successRate)
+ .prevSuccessRate(prevMetrics.successRate)
+ .avgLatencyMs(currentMetrics.avgLatencyMs)
+ .prevAvgLatencyMs(prevMetrics.avgLatencyMs)
+ .errorCount(currentMetrics.errorCount)
+ .topEventType(currentMetrics.topEventType)
+ .topUser(currentMetrics.topUser)
+ .eventsByType(currentMetrics.eventsByType)
+ .eventsByUser(currentMetrics.eventsByUser)
+ .topTools(currentMetrics.topTools)
+ .hourlyDistribution(hourlyDistribution)
+ .build())
+ .build();
}
/** Compute metrics from a list of audit events. */
@@ -520,31 +535,30 @@ public class AuditRestController {
* "date,username,tool,documentName,author,fileHash")
* @param eventTypes Filter by event type(s) - can be single value or array
* @param usernames Filter by username(s) - can be single value or array
- * @param startDate Filter start date
- * @param endDate Filter end date
+ * @param startDateStr Filter start date (ISO yyyy-MM-dd)
+ * @param endDateStr Filter end date (ISO yyyy-MM-dd)
* @return File download response
*/
- @GetMapping("/audit-export")
- public ResponseEntity exportAuditData(
- @RequestParam(value = "format", defaultValue = "csv") String format,
- @RequestParam(value = "fields", required = false) String fields,
- @RequestParam(value = "eventType", required = false) String[] eventTypes,
- @RequestParam(value = "username", required = false) String[] usernames,
- @RequestParam(value = "startDate", required = false)
- @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
- LocalDate startDate,
- @RequestParam(value = "endDate", required = false)
- @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
- LocalDate endDate) {
+ @GET
+ @jakarta.ws.rs.Path("/audit-export")
+ public Response exportAuditData(
+ @QueryParam("format") @jakarta.ws.rs.DefaultValue("csv") String format,
+ @QueryParam("fields") String fields,
+ @QueryParam("eventType") List eventTypes,
+ @QueryParam("username") List usernames,
+ @QueryParam("startDate") String startDateStr,
+ @QueryParam("endDate") String endDateStr) {
+
+ LocalDate startDate = parseIsoDate(startDateStr);
+ LocalDate endDate = parseIsoDate(endDateStr);
// Get data with same filtering as getAuditEvents
List events;
// Convert arrays to lists
List eventTypeList =
- (eventTypes != null && eventTypes.length > 0) ? Arrays.asList(eventTypes) : null;
- List usernameList =
- (usernames != null && usernames.length > 0) ? Arrays.asList(usernames) : null;
+ (eventTypes != null && !eventTypes.isEmpty()) ? eventTypes : null;
+ List usernameList = (usernames != null && !usernames.isEmpty()) ? usernames : null;
Instant startInstant = null;
Instant endInstant = null;
@@ -592,6 +606,19 @@ public class AuditRestController {
// Helper methods
+ /** Parse an ISO yyyy-MM-dd date string, returning null when blank/unparseable. */
+ private LocalDate parseIsoDate(String value) {
+ if (value == null || value.trim().isEmpty()) {
+ return null;
+ }
+ try {
+ return LocalDate.parse(value.trim());
+ } catch (Exception e) {
+ log.trace("Failed to parse ISO date value: {}", value);
+ return null;
+ }
+ }
+
private AuditEventDto convertToDto(PersistentAuditEvent event) {
// Parse the JSON data field if present
Map details = new HashMap<>();
@@ -628,7 +655,7 @@ public class AuditRestController {
.build();
}
- private ResponseEntity exportAsCsv(List events, String fields) {
+ private Response exportAsCsv(List events, String fields) {
// Parse selected fields (comma-separated:
// date,username,tool,documentName,author,fileHash,ipAddress,etc)
Set selectedFields = new HashSet<>();
@@ -680,15 +707,17 @@ public class AuditRestController {
}
byte[] csvBytes = csv.toString().getBytes(StandardCharsets.UTF_8);
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.parseMediaType("text/csv;charset=UTF-8"));
- headers.setContentDispositionFormData(
- "attachment", "audit_export_" + System.currentTimeMillis() + ".csv");
-
- return ResponseEntity.ok().headers(headers).body(csvBytes);
+ return Response.ok(csvBytes)
+ .header(HttpHeaders.CONTENT_TYPE, "text/csv;charset=UTF-8")
+ .header(
+ HttpHeaders.CONTENT_DISPOSITION,
+ "attachment; filename=\"audit_export_"
+ + System.currentTimeMillis()
+ + ".csv\"")
+ .build();
}
- private ResponseEntity exportAsDefaultCsv(List events) {
+ private Response exportAsDefaultCsv(List events) {
StringBuilder csv = new StringBuilder();
csv.append("ID,Principal,Type,Timestamp,Data\n");
@@ -703,11 +732,12 @@ public class AuditRestController {
}
byte[] csvBytes = csv.toString().getBytes(StandardCharsets.UTF_8);
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.parseMediaType("text/csv;charset=UTF-8"));
- headers.setContentDispositionFormData("attachment", "audit_export.csv");
-
- return ResponseEntity.ok().headers(headers).body(csvBytes);
+ return Response.ok(csvBytes)
+ .header(HttpHeaders.CONTENT_TYPE, "text/csv;charset=UTF-8")
+ .header(
+ HttpHeaders.CONTENT_DISPOSITION,
+ "attachment; filename=\"audit_export.csv\"")
+ .build();
}
private Map extractEventData(
@@ -798,18 +828,19 @@ public class AuditRestController {
};
}
- private ResponseEntity exportAsJson(List events) {
+ private Response exportAsJson(List events) {
try {
byte[] jsonBytes = objectMapper.writeValueAsBytes(events);
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.setContentDispositionFormData("attachment", "audit_export.json");
-
- return ResponseEntity.ok().headers(headers).body(jsonBytes);
+ return Response.ok(jsonBytes)
+ .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
+ .header(
+ HttpHeaders.CONTENT_DISPOSITION,
+ "attachment; filename=\"audit_export.json\"")
+ .build();
} catch (JacksonException e) {
log.error("Error serializing audit events to JSON", e);
- return ResponseEntity.internalServerError().build();
+ return Response.serverError().build();
}
}
@@ -900,18 +931,20 @@ public class AuditRestController {
*
* @return Success response
*/
- @PostMapping("/audit-clear-all")
- public ResponseEntity> clearAllAuditData() {
+ @POST
+ @jakarta.ws.rs.Path("/audit-clear-all")
+ public Response clearAllAuditData() {
try {
// Delete all audit events
auditRepository.deleteAll();
log.warn("All audit data has been cleared by admin user");
- return ResponseEntity.ok()
- .body(Map.of("message", "All audit data has been cleared successfully"));
+ return Response.ok(Map.of("message", "All audit data has been cleared successfully"))
+ .build();
} catch (Exception e) {
log.error("Error clearing audit data", e);
- return ResponseEntity.internalServerError()
- .body("Failed to clear audit data: " + e.getMessage());
+ return Response.serverError()
+ .entity("Failed to clear audit data: " + e.getMessage())
+ .build();
}
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java
index 15b7982dec..f81a29ac7c 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java
@@ -6,14 +6,14 @@ import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
import org.apache.pdfbox.pdmodel.PDDocument;
-import org.springframework.core.io.Resource;
-import org.springframework.http.MediaType;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
+import org.jboss.resteasy.reactive.RestForm;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Hidden;
@@ -39,8 +39,8 @@ import stirling.software.common.util.WebResponseUtils;
*/
@Slf4j
@Hidden
-@RestController
-@RequestMapping("/api/v1/ai/tools")
+@ApplicationScoped
+@jakarta.ws.rs.Path("/api/v1/ai/tools")
@RequiredArgsConstructor
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
public class CreatePdfAgentController {
@@ -70,18 +70,17 @@ public class CreatePdfAgentController {
return false;
}
- @PostMapping(
- value = "/create-pdf-from-html-agent",
- consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @POST
+ @jakarta.ws.rs.Path("/create-pdf-from-html-agent")
+ @Consumes(MediaType.MULTIPART_FORM_DATA)
@Operation(
summary = "Convert AI-generated HTML to a PDF",
description =
"Accepts an HTML document as a plain-text parameter and returns a PDF."
+ " This endpoint is dispatched by the AI workflow orchestrator as a"
+ " plan step; it is not intended for direct client use.")
- public ResponseEntity createPdfFromHtml(
- @RequestParam("htmlContent") String htmlContent,
- @RequestParam("filename") String filename)
+ public Response createPdfFromHtml(
+ @RestForm("htmlContent") String htmlContent, @RestForm("filename") String filename)
throws Exception {
log.info(
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java
index 20ca5c109f..683085154c 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java
@@ -3,14 +3,15 @@ package stirling.software.proprietary.controller.api;
import java.io.IOException;
import java.math.BigDecimal;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
-import org.springframework.web.multipart.MultipartFile;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+import org.jboss.resteasy.reactive.RestForm;
+import org.jboss.resteasy.reactive.multipart.FileUpload;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -19,6 +20,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.model.MultipartFile;
+import stirling.software.common.model.multipart.FileUploadMultipartFile;
import stirling.software.proprietary.model.api.ai.Verdict;
import stirling.software.proprietary.service.AiToolInputValidator;
import stirling.software.proprietary.service.MathAuditorOrchestrator;
@@ -29,26 +32,28 @@ import stirling.software.proprietary.service.MathAuditorOrchestrator;
* Accepts a PDF from the client, hands it to the {@link MathAuditorOrchestrator} which runs the
* multi-round Java-Python negotiation, and returns the Auditor's {@link Verdict} as JSON.
*
- *
This endpoint is a pure specialist — it produces the structured finding and nothing more.
+ *
This endpoint is a pure specialist - it produces the structured finding and nothing more.
* Presentation (rendering as a chat answer, projecting to PDF comments, etc.) is the responsibility
* of the caller (e.g. the orchestrator's {@code delegate_pdf_question} or {@code
* delegate_pdf_review} meta-agents).
*
*
Lives under {@code /api/v1/ai/tools/} so it is dispatchable by the AI orchestrator via the
- * standard {@code InternalApiClient} allowlist — no special-case plumbing needed.
+ * standard {@code InternalApiClient} allowlist - no special-case plumbing needed.
*
*
The raw PDF never leaves Java. Python receives only structured text and CSV data.
*/
@Slf4j
-@RestController
-@RequestMapping("/api/v1/ai/tools")
+@ApplicationScoped
+@Path("/api/v1/ai/tools")
@RequiredArgsConstructor
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
public class MathAuditorAgentController {
private final MathAuditorOrchestrator orchestrator;
- @PostMapping(value = "/math-auditor-agent", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @POST
+ @Path("/math-auditor-agent")
+ @Consumes(MediaType.MULTIPART_FORM_DATA)
@Operation(
summary = "Validate mathematical calculations in a PDF",
description =
@@ -67,34 +72,37 @@ public class MathAuditorAgentController {
Input: PDF Output: JSON Type: SISO
""")
- public ResponseEntity mathAuditorAgent(
+ public Response mathAuditorAgent(
@Parameter(description = "The PDF document to audit", required = true)
- @RequestParam("fileInput")
- MultipartFile fileInput,
+ @RestForm("fileInput")
+ FileUpload fileInput,
@Parameter(
description =
- "Arithmetic tolerance — differences smaller than this are"
+ "Arithmetic tolerance - differences smaller than this are"
+ " ignored (default: 0.01)")
- @RequestParam(value = "tolerance", defaultValue = "0.01")
+ @RestForm("tolerance")
BigDecimal tolerance) {
- AiToolInputValidator.validatePdfUpload(fileInput);
- if (tolerance.compareTo(BigDecimal.ZERO) < 0) {
- return ResponseEntity.badRequest().build();
+ BigDecimal effectiveTolerance = tolerance != null ? tolerance : new BigDecimal("0.01");
+
+ MultipartFile fileInputMpf = FileUploadMultipartFile.of(fileInput);
+ AiToolInputValidator.validatePdfUpload(fileInputMpf);
+ if (effectiveTolerance.compareTo(BigDecimal.ZERO) < 0) {
+ return Response.status(Response.Status.BAD_REQUEST).build();
}
String safeName =
- fileInput.getOriginalFilename() != null
- ? fileInput.getOriginalFilename().replaceAll("[\\r\\n]", "_")
+ fileInputMpf.getOriginalFilename() != null
+ ? fileInputMpf.getOriginalFilename().replaceAll("[\\r\\n]", "_")
: "";
- log.info("[math-auditor-agent] request file={} tolerance={}", safeName, tolerance);
+ log.info("[math-auditor-agent] request file={} tolerance={}", safeName, effectiveTolerance);
try {
- Verdict verdict = orchestrator.audit(fileInput, tolerance);
- return ResponseEntity.ok(verdict);
+ Verdict verdict = orchestrator.audit(fileInputMpf, effectiveTolerance);
+ return Response.ok(verdict).build();
} catch (IOException e) {
log.error("[math-auditor-agent] IO error during audit", e);
- return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
}
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java
index ef0ceaba25..f9d31251f8 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java
@@ -2,24 +2,27 @@ package stirling.software.proprietary.controller.api;
import java.io.IOException;
-import org.springframework.core.io.ByteArrayResource;
-import org.springframework.core.io.Resource;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.MediaType;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
-import org.springframework.web.multipart.MultipartFile;
-
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
-import lombok.RequiredArgsConstructor;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+import org.jboss.resteasy.reactive.RestForm;
+import org.jboss.resteasy.reactive.multipart.FileUpload;
+
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.model.MultipartFile;
+import stirling.software.common.model.multipart.FileUploadMultipartFile;
import stirling.software.proprietary.service.AiToolResponseHeaders;
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator;
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
@@ -39,19 +42,18 @@ import tools.jackson.databind.node.ObjectNode;
* The raw PDF never leaves Java. Python only receives positioned text chunks.
*/
@Slf4j
-@RestController
-@RequestMapping("/api/v1/ai/tools")
-@RequiredArgsConstructor
+@ApplicationScoped
+@Path("/api/v1/ai/tools")
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
public class PdfCommentAgentController {
- private final PdfCommentAgentOrchestrator orchestrator;
- private final ObjectMapper objectMapper;
+ @Inject PdfCommentAgentOrchestrator orchestrator;
+ @Inject ObjectMapper objectMapper;
- @PostMapping(
- value = "/pdf-comment-agent",
- consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
- produces = MediaType.APPLICATION_PDF_VALUE)
+ @POST
+ @Path("/pdf-comment-agent")
+ @Consumes(MediaType.MULTIPART_FORM_DATA)
+ @Produces("application/pdf")
@Operation(
summary = "Annotate a PDF with AI-generated sticky-note comments",
description =
@@ -66,18 +68,20 @@ public class PdfCommentAgentController {
Input: PDF + prompt Output: PDF Type: SISO
""")
- public ResponseEntity pdfCommentAgent(
+ public Response pdfCommentAgent(
@Parameter(description = "The PDF document to annotate", required = true)
- @RequestParam("fileInput")
- MultipartFile fileInput,
+ @RestForm("fileInput")
+ FileUpload fileInputUpload,
@Parameter(
description =
- "Natural-language instructions for the AI — what to comment on",
+ "Natural-language instructions for the AI - what to comment on",
required = true)
- @RequestParam("prompt")
+ @RestForm("prompt")
String prompt)
throws IOException {
+ MultipartFile fileInput = FileUploadMultipartFile.of(fileInputUpload);
+
String safeName =
fileInput.getOriginalFilename() != null
? fileInput.getOriginalFilename().replaceAll("[\\r\\n]", "_")
@@ -87,15 +91,19 @@ public class PdfCommentAgentController {
safeName,
prompt == null ? 0 : prompt.length());
- // ResponseStatusException (validation errors) propagates to Spring's default handler;
+ // ResponseStatusException (validation errors) propagates to the default handler;
// IOException is re-thrown to produce a 500. Other RuntimeExceptions likewise propagate.
AnnotatedPdf annotated = orchestrator.applyComments(fileInput, prompt);
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_PDF);
- headers.setContentDispositionFormData("attachment", annotated.fileName());
- headers.setContentLength(annotated.bytes().length);
- headers.set(AiToolResponseHeaders.TOOL_REPORT, buildReportHeader(annotated));
- return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(annotated.bytes()));
+ return Response.ok(annotated.bytes())
+ .type("application/pdf")
+ .header(HttpHeaders.CONTENT_LENGTH, annotated.bytes().length)
+ .header(
+ "Content-Disposition",
+ "form-data; name=\"attachment\"; filename=\""
+ + annotated.fileName()
+ + "\"")
+ .header(AiToolResponseHeaders.TOOL_REPORT, buildReportHeader(annotated))
+ .build();
}
/**
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java
index 161c1d7055..bd82abe2f9 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java
@@ -6,14 +6,16 @@ import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
-import org.springframework.beans.factory.annotation.Qualifier;
-import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.userdetails.UserDetails;
-import org.springframework.security.oauth2.core.user.OAuth2User;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
+import jakarta.annotation.security.RolesAllowed;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.core.Response;
+
+import io.quarkus.security.identity.SecurityIdentity;
import io.swagger.v3.oas.annotations.Operation;
@@ -45,7 +47,6 @@ import stirling.software.proprietary.security.model.SessionEntity;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.dto.AdminUserSummary;
import stirling.software.proprietary.security.repository.TeamRepository;
-import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
@@ -57,6 +58,8 @@ import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
@Slf4j
+@ApplicationScoped
+@Path("/api/v1/proprietary/ui-data")
@ProprietaryUiDataApi
public class ProprietaryUIDataController {
@@ -74,6 +77,9 @@ public class ProprietaryUIDataController {
private final MfaService mfaService;
private final LoginAttemptService loginAttemptService;
+ @Inject SecurityIdentity securityIdentity;
+
+ @Inject
public ProprietaryUIDataController(
ApplicationProperties applicationProperties,
AuditConfigurationProperties auditConfig,
@@ -83,7 +89,7 @@ public class ProprietaryUIDataController {
SessionRepository sessionRepository,
DatabaseServiceInterface databaseService,
ObjectMapper objectMapper,
- @Qualifier("runningEE") boolean runningEE,
+ @Named("runningEE") boolean runningEE,
UserLicenseSettingsService licenseSettingsService,
PersistentAuditEventRepository auditRepository,
MfaService mfaService,
@@ -119,11 +125,12 @@ public class ProprietaryUIDataController {
return "http://localhost:8080";
}
- @GetMapping("/audit-dashboard")
- @PreAuthorize("hasRole('ADMIN')")
+ @GET
+ @Path("/audit-dashboard")
+ @RolesAllowed("ADMIN")
@EnterpriseEndpoint
@Operation(summary = "Get audit dashboard data")
- public ResponseEntity getAuditDashboardData() {
+ public Response getAuditDashboardData() {
AuditDashboardData data = new AuditDashboardData();
data.setAuditEnabled(auditConfig.isEnabled());
data.setAuditLevel(auditConfig.getAuditLevel());
@@ -139,12 +146,13 @@ public class ProprietaryUIDataController {
data.setPdfMetadataEnabled(
auditConfig.isCaptureFileHash() || auditConfig.isCapturePdfAuthor());
- return ResponseEntity.ok(data);
+ return Response.ok(data).build();
}
- @GetMapping("/login")
+ @GET
+ @Path("/login")
@Operation(summary = "Get login page data")
- public ResponseEntity getLoginData() {
+ public Response getLoginData() {
LoginData data = new LoginData();
Map providerList = new HashMap<>();
Security securityProps = applicationProperties.getSecurity();
@@ -247,13 +255,14 @@ public class ProprietaryUIDataController {
data.setLanguages(applicationProperties.getUi().getLanguages());
data.setDefaultLocale(applicationProperties.getSystem().getDefaultLocale());
- return ResponseEntity.ok(data);
+ return Response.ok(data).build();
}
- @GetMapping("/admin-settings")
- @PreAuthorize("hasRole('ADMIN')")
+ @GET
+ @Path("/admin-settings")
+ @RolesAllowed("ADMIN")
@Operation(summary = "Get admin settings data")
- public ResponseEntity getAdminSettingsData(Authentication authentication) {
+ public Response getAdminSettingsData() {
List allUsers = userRepository.findAllWithTeam();
Iterator iterator = allUsers.iterator();
Map roleDetails = Role.getAllRoleDetails();
@@ -375,7 +384,7 @@ public class ProprietaryUIDataController {
AdminSettingsData data = new AdminSettingsData();
data.setUsers(userSummaries);
- data.setCurrentUsername(authentication.getName());
+ data.setCurrentUsername(securityIdentity.getPrincipal().getName());
data.setRoleDetails(roleDetails);
data.setUserSessions(userSessions);
data.setUserLastRequest(userLastRequest);
@@ -393,39 +402,37 @@ public class ProprietaryUIDataController {
data.setUserSettings(userSettings);
data.setLockedUsers(loginAttemptService.getAllBlockedUsers());
- return ResponseEntity.ok(data);
+ return Response.ok(data).build();
}
- @GetMapping("/account")
- @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
+ @GET
+ @Path("/account")
+ // TODO: Migration required - Spring "!hasAuthority('ROLE_DEMO_USER')" (negated authority) has
+ // no @RolesAllowed equivalent. Enforce the DEMO_USER exclusion via a Quarkus
+ // SecurityIdentity check below / an augmentor, or quarkus.http.auth.* policy.
@Operation(summary = "Get account page data")
- public ResponseEntity getAccountData(Authentication authentication) {
- if (authentication == null || !authentication.isAuthenticated()) {
- return ResponseEntity.status(401).build();
+ public Response getAccountData() {
+ if (securityIdentity == null || securityIdentity.isAnonymous()) {
+ return Response.status(Response.Status.UNAUTHORIZED).build();
}
- Object principal = authentication.getPrincipal();
- String username = null;
+ // TODO: Migration required - Spring distinguished UserDetails / OAuth2User /
+ // CustomSaml2AuthenticatedPrincipal off authentication.getPrincipal() to set the
+ // oAuth2Login / saml2Login flags. Under Quarkus the auth mechanism is exposed via
+ // SecurityIdentity attributes (e.g. quarkus-oidc IdToken / SAML augmentor). Until OAuth2/
+ // SAML are wired to quarkus-oidc, only the username is resolved and the login-type flags
+ // default to false.
+ String username = securityIdentity.getPrincipal().getName();
boolean isOAuth2Login = false;
boolean isSaml2Login = false;
- if (principal instanceof UserDetails detailsUser) {
- username = detailsUser.getUsername();
- } else if (principal instanceof OAuth2User oAuth2User) {
- username = oAuth2User.getName();
- isOAuth2Login = true;
- } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
- username = saml2User.name();
- isSaml2Login = true;
- }
-
if (username == null) {
- return ResponseEntity.status(401).build();
+ return Response.status(Response.Status.UNAUTHORIZED).build();
}
Optional user = userRepository.findByUsernameIgnoreCaseWithSettings(username);
if (user.isEmpty()) {
- return ResponseEntity.status(404).build();
+ return Response.status(Response.Status.NOT_FOUND).build();
}
String settingsJson;
@@ -433,7 +440,7 @@ public class ProprietaryUIDataController {
settingsJson = objectMapper.writeValueAsString(user.get().getSettings());
} catch (JacksonException e) {
log.error("Error converting settings map", e);
- return ResponseEntity.status(500).build();
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
}
AccountData data = new AccountData();
@@ -446,13 +453,14 @@ public class ProprietaryUIDataController {
data.setMfaEnabled(mfaService.isMfaEnabled(user.get()));
data.setMfaRequired(mfaService.isMfaRequired(user.get()));
- return ResponseEntity.ok(data);
+ return Response.ok(data).build();
}
- @GetMapping("/teams")
- @PreAuthorize("hasRole('ADMIN')")
+ @GET
+ @Path("/teams")
+ @RolesAllowed("ADMIN")
@Operation(summary = "Get teams list data")
- public ResponseEntity getTeamsData() {
+ public Response getTeamsData() {
List allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
List teamsWithCounts =
allTeamsWithCounts.stream()
@@ -472,20 +480,21 @@ public class ProprietaryUIDataController {
data.setTeamsWithCounts(teamsWithCounts);
data.setTeamLastRequest(teamLastRequest);
- return ResponseEntity.ok(data);
+ return Response.ok(data).build();
}
- @GetMapping("/teams/{id}")
- @PreAuthorize("hasRole('ADMIN')")
+ @GET
+ @Path("/teams/{id}")
+ @RolesAllowed("ADMIN")
@Operation(summary = "Get team details data")
- public ResponseEntity getTeamDetailsData(@PathVariable("id") Long id) {
+ public Response getTeamDetailsData(@PathParam("id") Long id) {
Team team =
teamRepository
.findById(id)
.orElseThrow(() -> new RuntimeException("Team not found"));
if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) {
- return ResponseEntity.status(403).build();
+ return Response.status(Response.Status.FORBIDDEN).build();
}
List teamUsers = userRepository.findAllByTeamId(id);
@@ -516,13 +525,14 @@ public class ProprietaryUIDataController {
data.setAvailableUsers(availableUsers);
data.setUserLastRequest(userLastRequest);
- return ResponseEntity.ok(data);
+ return Response.ok(data).build();
}
- @GetMapping("/database")
- @PreAuthorize("hasRole('ADMIN')")
+ @GET
+ @Path("/database")
+ @RolesAllowed("ADMIN")
@Operation(summary = "Get database management data")
- public ResponseEntity getDatabaseData() {
+ public Response getDatabaseData() {
List backupList = databaseService.getBackupList();
String dbVersion = databaseService.getH2Version();
boolean isVersionUnknown = "Unknown".equalsIgnoreCase(dbVersion);
@@ -532,7 +542,7 @@ public class ProprietaryUIDataController {
data.setDatabaseVersion(dbVersion);
data.setVersionUnknown(isVersionUnknown);
- return ResponseEntity.ok(data);
+ return Response.ok(data).build();
}
/**
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java
index 3295e62abb..1be1cc98dd 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java
@@ -8,16 +8,15 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.DeleteMapping;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.DELETE;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -32,12 +31,19 @@ import stirling.software.proprietary.service.SignatureService;
/**
* Controller for managing user signatures in proprietary/authenticated mode only. Requires user
- * authentication and enforces per-user storage limits. All endpoints require authentication
- * via @PreAuthorize("isAuthenticated()").
+ * authentication and enforces per-user storage limits.
+ *
+ * TODO: Migration required - the original endpoints were guarded by Spring Security SpEL
+ * expressions ({@code @PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")} and
+ * {@code @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")}). These are not simple role checks, so
+ * they cannot be expressed with {@code @RolesAllowed}. Authentication should be enforced via Quarkus
+ * (e.g. inject {@code io.quarkus.security.identity.SecurityIdentity} or add an HTTP auth policy in
+ * application.properties), and the DEMO_USER exclusion needs to be re-implemented as a runtime check
+ * against the current identity's roles.
*/
@Slf4j
-@RestController
-@RequestMapping("/api/v1/proprietary/signatures")
+@ApplicationScoped
+@jakarta.ws.rs.Path("/api/v1/proprietary/signatures")
@RequiredArgsConstructor
@Tag(
name = "Saved Signatures",
@@ -52,10 +58,13 @@ public class SignatureController {
* Save a new signature for the authenticated user. Enforces storage limits and authentication
* requirements.
*/
- @PostMapping
- @PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
- public ResponseEntity saveSignature(
- @RequestBody SavedSignatureRequest request) {
+ // TODO: Migration required - replace @PreAuthorize("isAuthenticated() &&
+ // !hasAuthority('ROLE_DEMO_USER')") with a Quarkus authentication policy + DEMO_USER runtime
+ // guard.
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response saveSignature(SavedSignatureRequest request) {
try {
String username = userService.getCurrentUsername();
@@ -63,24 +72,24 @@ public class SignatureController {
log.warn(
"User {} attempted to create shared signature without admin role",
username);
- return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+ return Response.status(Response.Status.FORBIDDEN).build();
}
// Validate request
if (request.getDataUrl() == null || request.getDataUrl().isEmpty()) {
log.warn("User {} attempted to save signature without dataUrl", username);
- return ResponseEntity.badRequest().build();
+ return Response.status(Response.Status.BAD_REQUEST).build();
}
SavedSignatureResponse response = signatureService.saveSignature(username, request);
log.info("User {} saved signature {}", username, request.getId());
- return ResponseEntity.ok(response);
+ return Response.ok(response).build();
} catch (IllegalArgumentException e) {
log.warn("Invalid signature save request: {}", e.getMessage());
- return ResponseEntity.badRequest().build();
+ return Response.status(Response.Status.BAD_REQUEST).build();
} catch (IOException e) {
log.error("Failed to save signature", e);
- return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
}
}
@@ -88,16 +97,19 @@ public class SignatureController {
* List all signatures accessible to the authenticated user. Includes both personal and shared
* signatures.
*/
- @GetMapping
- @PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
- public ResponseEntity> listSignatures() {
+ // TODO: Migration required - replace @PreAuthorize("isAuthenticated() &&
+ // !hasAuthority('ROLE_DEMO_USER')") with a Quarkus authentication policy + DEMO_USER runtime
+ // guard.
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response listSignatures() {
try {
String username = userService.getCurrentUsername();
List signatures = signatureService.getSavedSignatures(username);
- return ResponseEntity.ok(signatures);
+ return Response.ok(signatures).build();
} catch (IOException e) {
log.error("Failed to list signatures for user", e);
- return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
}
}
@@ -105,10 +117,13 @@ public class SignatureController {
* Update a signature label. Users can update labels for their own personal signatures and for
* shared signatures.
*/
- @PostMapping("/{signatureId}/label")
- @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
- public ResponseEntity updateSignatureLabel(
- @PathVariable String signatureId, @RequestBody Map body) {
+ // TODO: Migration required - replace @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") with a
+ // DEMO_USER runtime guard against the current identity's roles.
+ @POST
+ @jakarta.ws.rs.Path("/{signatureId}/label")
+ @Consumes(MediaType.APPLICATION_JSON)
+ public Response updateSignatureLabel(
+ @PathParam("signatureId") String signatureId, Map body) {
try {
String username = userService.getCurrentUsername();
String newLabel = body.get("label");
@@ -116,7 +131,7 @@ public class SignatureController {
if (newLabel == null || newLabel.trim().isEmpty()) {
log.warn("Invalid label update request");
- return ResponseEntity.badRequest().build();
+ return Response.status(Response.Status.BAD_REQUEST).build();
}
if (signatureService.isSharedSignature(signatureId) && !isAdmin) {
@@ -124,15 +139,15 @@ public class SignatureController {
"User {} attempted to update shared signature {} without admin role",
username,
signatureId);
- return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+ return Response.status(Response.Status.FORBIDDEN).build();
}
signatureService.updateSignatureLabel(username, signatureId, newLabel);
log.info("User {} updated label for signature {}", username, signatureId);
- return ResponseEntity.noContent().build();
+ return Response.noContent().build();
} catch (IOException e) {
log.warn("Failed to update signature label: {}", e.getMessage());
- return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
+ return Response.status(Response.Status.NOT_FOUND).build();
}
}
@@ -140,9 +155,11 @@ public class SignatureController {
* Delete a signature owned by the authenticated user. Users can delete their own personal
* signatures. Admins can also delete shared signatures.
*/
- @DeleteMapping("/{signatureId}")
- @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
- public ResponseEntity deleteSignature(@PathVariable String signatureId) {
+ // TODO: Migration required - replace @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") with a
+ // DEMO_USER runtime guard against the current identity's roles.
+ @DELETE
+ @jakarta.ws.rs.Path("/{signatureId}")
+ public Response deleteSignature(@PathParam("signatureId") String signatureId) {
try {
String username = userService.getCurrentUsername();
boolean isAdmin = userService.isCurrentUserAdmin();
@@ -152,21 +169,21 @@ public class SignatureController {
|| signatureId.contains("/")
|| signatureId.contains("\\")) {
log.warn("Invalid signature ID: {}", signatureId);
- return ResponseEntity.badRequest().build();
+ return Response.status(Response.Status.BAD_REQUEST).build();
}
// Try to delete from personal folder first
try {
signatureService.deleteSignature(username, signatureId);
log.info("User {} deleted personal signature {}", username, signatureId);
- return ResponseEntity.noContent().build();
+ return Response.noContent().build();
} catch (IOException e) {
// If not found in personal folder, check if it's in shared folder
if (isAdmin) {
// Admin can delete from shared folder
if (deleteFromSharedFolder(signatureId)) {
log.info("Admin {} deleted shared signature {}", username, signatureId);
- return ResponseEntity.noContent().build();
+ return Response.noContent().build();
}
}
// If not admin or not found in shared folder either, return 404
@@ -174,7 +191,7 @@ public class SignatureController {
}
} catch (IOException e) {
log.warn("Failed to delete signature {} for user: {}", signatureId, e.getMessage());
- return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
+ return Response.status(Response.Status.NOT_FOUND).build();
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java
index 9f4fa470ba..5bb0b850b9 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java
@@ -5,10 +5,13 @@ import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
-import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestParam;
+import jakarta.annotation.security.RolesAllowed;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.ws.rs.DefaultValue;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -24,8 +27,10 @@ import tools.jackson.databind.ObjectMapper;
/** REST API controller for usage analytics data used by React frontend. */
@Slf4j
+@ApplicationScoped
@ProprietaryUiDataApi
-@PreAuthorize("hasRole('ADMIN')")
+@Path("/api/v1/proprietary/ui-data")
+@RolesAllowed("ADMIN")
@RequiredArgsConstructor
@EnterpriseEndpoint
public class UsageRestController {
@@ -43,11 +48,12 @@ public class UsageRestController {
* @param days Lookback window in days (default 30, clamped to 1-365)
* @return Endpoint statistics response
*/
- @GetMapping("/usage-endpoint-statistics")
- public ResponseEntity getEndpointStatistics(
- @RequestParam(value = "limit", required = false) Integer limit,
- @RequestParam(value = "dataType", defaultValue = "all") String dataType,
- @RequestParam(value = "days", defaultValue = "30") Integer days) {
+ @GET
+ @Path("/usage-endpoint-statistics")
+ public Response getEndpointStatistics(
+ @QueryParam("limit") Integer limit,
+ @QueryParam("dataType") @DefaultValue("all") String dataType,
+ @QueryParam("days") @DefaultValue("30") Integer days) {
int lookbackDays = Math.max(1, Math.min(days, 365));
@@ -99,7 +105,7 @@ public class UsageRestController {
.totalVisits((int) totalVisits)
.build();
- return ResponseEntity.ok(response);
+ return Response.ok(response).build();
}
/**
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpServerController.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpServerController.java
index d3103b8d4c..b54f46c8ac 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpServerController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpServerController.java
@@ -5,16 +5,15 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
-import org.springframework.http.ResponseEntity;
-import org.springframework.http.converter.HttpMessageNotReadableException;
-import org.springframework.web.bind.annotation.ExceptionHandler;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+import io.quarkus.arc.lookup.LookupIfProperty;
+import io.quarkus.security.identity.SecurityIdentity;
import lombok.extern.slf4j.Slf4j;
@@ -30,9 +29,14 @@ import tools.jackson.databind.node.ObjectNode;
/** Streamable-HTTP MCP server endpoint serving JSON-RPC 2.0 frames on {@code POST /mcp}. */
@Slf4j
-@RestController
-@RequestMapping
-@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
+@ApplicationScoped
+@jakarta.ws.rs.Path("/mcp")
+// @ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") -> LookupIfProperty.
+// LookupIfProperty gates programmatic lookup; for a JAX-RS resource Quarkus always registers the
+// endpoint. TODO: Migration required - to truly disable the /mcp route when mcp.enabled=false,
+// add a runtime guard (e.g. reject in handle() when disabled) or use a build-time conditional;
+// LookupIfProperty alone does not unregister the REST path.
+@LookupIfProperty(name = "mcp.enabled", stringValue = "true")
public class McpServerController {
private static final String PREFERRED_PROTOCOL_VERSION = "2025-06-18";
@@ -44,6 +48,9 @@ public class McpServerController {
private final ApplicationProperties applicationProperties;
private final Map toolsByName;
+ @Inject SecurityIdentity securityIdentity;
+
+ @Inject
public McpServerController(
ObjectMapper mapper, ApplicationProperties applicationProperties, List tools) {
this.mapper = mapper;
@@ -58,24 +65,24 @@ public class McpServerController {
toolsByName.keySet());
}
- @PostMapping(
- path = "/mcp",
- consumes = MediaType.APPLICATION_JSON_VALUE,
- produces = MediaType.APPLICATION_JSON_VALUE)
- public ResponseEntity> handle(@RequestBody JsonNode body) {
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @jakarta.ws.rs.Produces(MediaType.APPLICATION_JSON)
+ public Response handle(JsonNode body) {
JsonRpcRequest request = decode(body);
if (request == null) {
// Valid JSON but not a JSON-RPC request -> Invalid Request, not Parse error.
- return ResponseEntity.badRequest()
- .body(
+ return Response.status(Response.Status.BAD_REQUEST)
+ .entity(
JsonRpcResponse.failure(
null,
JsonRpcError.invalidRequest(
- "Body is not a valid JSON-RPC 2.0 request")));
+ "Body is not a valid JSON-RPC 2.0 request")))
+ .build();
}
if (request.isNotification()) {
log.debug("Notification received: {}", sanitizeForLog(request.method()));
- return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
+ return Response.status(Response.Status.NO_CONTENT).build();
}
JsonRpcResponse response;
try {
@@ -92,17 +99,23 @@ public class McpServerController {
JsonRpcError.internalError(
"Internal error handling " + request.method()));
}
- return ResponseEntity.ok(response);
+ return Response.ok(response).build();
}
- /** Wrap malformed-JSON failures (caught before {@link #handle}) as a JSON-RPC Parse error. */
- @ExceptionHandler(HttpMessageNotReadableException.class)
- public ResponseEntity handleUnreadable(HttpMessageNotReadableException ex) {
- return ResponseEntity.badRequest()
- .contentType(MediaType.APPLICATION_JSON)
- .body(
+ // Spring's @ExceptionHandler(HttpMessageNotReadableException.class) wrapped malformed-JSON
+ // failures as a JSON-RPC Parse error. In JAX-RS this maps to a
+ // jakarta.ws.rs.ext.ExceptionMapper provider. TODO: Migration required - move this handling to
+ // a @Provider ExceptionMapper<...> (e.g. mapping the JSON deserialization exception thrown by
+ // the Jackson MessageBodyReader) returning HTTP 400 with
+ // JsonRpcResponse.failure(null, JsonRpcError.parseError("Request body is not valid JSON")).
+ // Kept here for reference; it is no longer wired as an exception handler.
+ private Response handleUnreadable() {
+ return Response.status(Response.Status.BAD_REQUEST)
+ .type(MediaType.APPLICATION_JSON)
+ .entity(
JsonRpcResponse.failure(
- null, JsonRpcError.parseError("Request body is not valid JSON")));
+ null, JsonRpcError.parseError("Request body is not valid JSON")))
+ .build();
}
private static String sanitizeForLog(String value) {
@@ -197,21 +210,32 @@ public class McpServerController {
private McpCallContext resolveContext() {
boolean scopesEnabled = applicationProperties.getMcp().isScopesEnabled();
- org.springframework.security.core.Authentication auth =
- org.springframework.security.core.context.SecurityContextHolder.getContext()
- .getAuthentication();
- // Fail closed: no/unauthenticated principal yields an empty context so scoped ops are
- // refused.
- if (auth == null || !auth.isAuthenticated() || auth.getName() == null) {
+ // Spring SecurityContextHolder.getContext().getAuthentication() -> Quarkus SecurityIdentity.
+ // Fail closed: an anonymous/unauthenticated identity yields an empty context so scoped ops
+ // are refused.
+ if (securityIdentity == null
+ || securityIdentity.isAnonymous()
+ || securityIdentity.getPrincipal() == null
+ || securityIdentity.getPrincipal().getName() == null) {
return new McpCallContext(null, Set.of(), scopesEnabled);
}
java.util.Set scopes = new java.util.HashSet<>();
- for (org.springframework.security.core.GrantedAuthority ga : auth.getAuthorities()) {
- String authority = ga.getAuthority();
- if (authority != null && authority.startsWith("SCOPE_")) {
- scopes.add(authority.substring("SCOPE_".length()));
+ // TODO: Migration required - the Spring code derived scopes from GrantedAuthority values
+ // prefixed with "SCOPE_". Quarkus SecurityIdentity.getRoles() typically already carries the
+ // bare role/scope names (quarkus-oidc maps OIDC scopes to roles without the SCOPE_ prefix).
+ // Confirm the configured quarkus.oidc role/scope mapping; if scopes arrive as a "scope"
+ // claim, read them via securityIdentity.getAttribute("scope")/getClaims() instead. For now
+ // we accept both the bare role and any "SCOPE_"-prefixed authority for parity.
+ for (String role : securityIdentity.getRoles()) {
+ if (role == null) {
+ continue;
+ }
+ if (role.startsWith("SCOPE_")) {
+ scopes.add(role.substring("SCOPE_".length()));
+ } else {
+ scopes.add(role);
}
}
- return new McpCallContext(auth.getName(), scopes, scopesEnabled);
+ return new McpCallContext(securityIdentity.getPrincipal().getName(), scopes, scopesEnabled);
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/McpToolCatalog.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/McpToolCatalog.java
index 2821c9181f..df61da2e31 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/McpToolCatalog.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/McpToolCatalog.java
@@ -2,25 +2,17 @@ package stirling.software.proprietary.mcp.catalog;
import java.lang.reflect.Method;
import java.util.ArrayList;
-import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
-import java.util.Set;
-import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.context.ApplicationContext;
-import org.springframework.context.event.ContextRefreshedEvent;
-import org.springframework.context.event.EventListener;
-import org.springframework.core.MethodParameter;
-import org.springframework.stereotype.Component;
-import org.springframework.web.bind.annotation.RequestMethod;
-import org.springframework.web.method.HandlerMethod;
-import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
-import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.event.Observes;
+import jakarta.inject.Inject;
+
+import io.quarkus.runtime.StartupEvent;
import io.swagger.v3.oas.annotations.Operation;
@@ -33,19 +25,23 @@ import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
- * Discovers MCP-exposable operations and caches a per-op {@link OperationMeta}. Refreshed on {@link
- * ContextRefreshedEvent} and filtered on read by {@link
+ * Discovers MCP-exposable operations and caches a per-op {@link OperationMeta}. Refreshed on
+ * application startup ({@code @Observes StartupEvent}) and filtered on read by {@link
* EndpointConfiguration#isEndpointEnabledForUri}. AI capabilities are fed in via {@link
* #replaceAiCapabilities}.
*/
@Slf4j
-@Component
-@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
+@ApplicationScoped
+// TODO: Migration required - the original @ConditionalOnProperty(name = "mcp.enabled",
+// havingValue = "true") gated this bean on a runtime property. Quarkus build-time conditions
+// (@io.quarkus.arc.lookup.LookupIfProperty / @io.quarkus.arc.profile.IfBuildProfile) cannot honour
+// a purely runtime toggle. The bean is now always present; callers must guard on
+// applicationProperties.getMcp() / a runtime "mcp.enabled" check, or wire @LookupIfProperty on the
+// injection points once "mcp.enabled" is promoted to a build-time property.
public class McpToolCatalog {
private static final String WRITE_SCOPE = "mcp.tools.write";
- private final ApplicationContext applicationContext;
private final EndpointConfiguration endpointConfiguration;
private final ApplicationProperties applicationProperties;
private final SimpleSchemaGenerator schemaGenerator;
@@ -60,12 +56,11 @@ public class McpToolCatalog {
// never a partially-merged one.
private volatile Map aiOps = new ConcurrentHashMap<>();
+ @Inject
public McpToolCatalog(
- ApplicationContext applicationContext,
EndpointConfiguration endpointConfiguration,
ApplicationProperties applicationProperties,
ObjectMapper objectMapper) {
- this.applicationContext = applicationContext;
this.endpointConfiguration = endpointConfiguration;
this.applicationProperties = applicationProperties;
this.schemaGenerator = new SimpleSchemaGenerator(objectMapper);
@@ -86,52 +81,38 @@ public class McpToolCatalog {
return true;
}
- @EventListener(ContextRefreshedEvent.class)
- public void discover() {
+ void discover(@Observes StartupEvent event) {
pdfOps.clear();
- for (RequestMappingHandlerMapping mapping :
- applicationContext.getBeansOfType(RequestMappingHandlerMapping.class).values()) {
- for (Map.Entry e :
- mapping.getHandlerMethods().entrySet()) {
- indexOne(e.getKey(), e.getValue());
- }
- }
+ // TODO: Migration required - endpoint discovery relied on Spring MVC's
+ // RequestMappingHandlerMapping (ApplicationContext.getBeansOfType(...) ->
+ // mapping.getHandlerMethods()) to enumerate every @RequestMapping/@PostMapping handler,
+ // its URL patterns (RequestMappingInfo#getDirectPaths), its HTTP methods
+ // (RequestMethod POST/PUT), and the HandlerMethod/MethodParameter reflection used to build
+ // request schemas. Quarkus/RESTEasy Reactive has no equivalent runtime registry of JAX-RS
+ // resources. To restore catalog population, replace this with one of:
+ // (a) a build-time scan via a Quarkus extension / @io.quarkus.runtime.annotations.Recorder
+ // over Jandex-indexed @Path + @POST/@PUT methods, or
+ // (b) a custom registry populated as endpoints register themselves, or
+ // (c) classpath reflection (Jandex CombinedIndexBuildItem) over the @XxxApi-annotated
+ // resource classes.
+ // The per-handler helpers below (buildMeta/paramSchemaFor/firstComplexParamType/indexOne/
+ // extractPatterns/isInvocableMethod) all depended on Spring MVC types and have been removed;
+ // the schema-generation logic (SimpleSchemaGenerator) and OperationMeta model are reusable
+ // once a Quarkus-native handler enumeration is supplied.
log.info("MCP tool catalog discovered {} PDF operation(s)", pdfOps.size());
}
- private void indexOne(RequestMappingInfo info, HandlerMethod handler) {
- Set patterns = extractPatterns(info);
- if (patterns.isEmpty()) {
- return;
- }
- Set methods = info.getMethodsCondition().getMethods();
- if (!isInvocableMethod(methods)) {
- return;
- }
- for (String pattern : patterns) {
- OperationCategory category = OperationCategory.fromUrl(pattern);
- if (category == null) {
- continue;
- }
- String opId = extractOpId(pattern, category);
- if (opId == null) {
- continue;
- }
- OperationMeta meta = buildMeta(opId, category, pattern, handler);
- // First handler wins on duplicate URLs.
- pdfOps.putIfAbsent(opId, meta);
- }
- }
-
private OperationMeta buildMeta(
- String opId, OperationCategory category, String url, HandlerMethod handler) {
- Method method = handler.getMethod();
+ String opId, OperationCategory category, String url, Method method) {
Operation opAnno = method.getAnnotation(Operation.class);
String summary =
opAnno != null && !opAnno.summary().isBlank()
? opAnno.summary()
: prettifyOpId(opId);
- ObjectNode schema = paramSchemaFor(handler);
+ // TODO: Migration required - request body type was previously resolved from Spring's
+ // HandlerMethod#getMethodParameters(); resolve the first complex parameter type via plain
+ // reflection on the JAX-RS resource method instead, then call schemaGenerator.toSchema(...).
+ ObjectNode schema = paramSchemaFor(method);
// Every mutating endpoint requires the write scope.
return new OperationMeta(
opId,
@@ -141,11 +122,11 @@ public class McpToolCatalog {
WRITE_SCOPE,
OperationMeta.Target.JAVA_ENDPOINT,
url,
- handler);
+ method);
}
- private ObjectNode paramSchemaFor(HandlerMethod handler) {
- Optional> bodyType = firstComplexParamType(handler);
+ private ObjectNode paramSchemaFor(Method method) {
+ Optional> bodyType = firstComplexParamType(method);
return bodyType.map(schemaGenerator::toSchema).orElseGet(() -> emptyObjectSchema());
}
@@ -156,13 +137,12 @@ public class McpToolCatalog {
return out;
}
- private Optional> firstComplexParamType(HandlerMethod handler) {
- for (MethodParameter p : handler.getMethodParameters()) {
- Class> type = p.getParameterType();
+ private Optional> firstComplexParamType(Method method) {
+ for (Class> type : method.getParameterTypes()) {
if (type.isPrimitive() || type == String.class || type.getName().startsWith("java.")) {
continue;
}
- // Skip Spring-managed parameter types (HttpServletRequest, Principal, etc.).
+ // Skip container-managed parameter types (HttpServletRequest, Principal, etc.).
String pkg = type.getPackageName();
if (pkg.startsWith("jakarta.") || pkg.startsWith("org.springframework.")) {
continue;
@@ -220,46 +200,10 @@ public class McpToolCatalog {
log.info("MCP tool catalog AI capabilities replaced: {} entries", next.size());
}
- /** Only POST/PUT endpoints are exposed as tools; DELETE and GET are excluded. */
- static boolean isInvocableMethod(Set methods) {
- return methods.contains(RequestMethod.POST) || methods.contains(RequestMethod.PUT);
- }
-
- private static String extractOpId(String pattern, OperationCategory category) {
- if (category.urlPrefix() == null || !pattern.startsWith(category.urlPrefix())) {
- return null;
- }
- String tail = pattern.substring(category.urlPrefix().length());
- if (tail.isBlank() || tail.contains("/") || tail.contains("{")) {
- // Skip nested paths and path-variable templates.
- return null;
- }
- return tail;
- }
-
private static String prettifyOpId(String id) {
return id.replace('-', ' ');
}
- private static Set extractPatterns(RequestMappingInfo info) {
- try {
- Method getDirectPaths = info.getClass().getMethod("getDirectPaths");
- Object result = getDirectPaths.invoke(info);
- if (result instanceof Set> set) {
- Set patterns = new TreeSet<>();
- for (Object v : set) {
- if (v instanceof String s) {
- patterns.add(s);
- }
- }
- return patterns;
- }
- } catch (Exception e) {
- log.trace("getDirectPaths unavailable on RequestMappingInfo", e);
- }
- return Collections.emptySet();
- }
-
public Map snapshotPdfOps() {
return new LinkedHashMap<>(pdfOps);
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationMeta.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationMeta.java
index a9d8b2a9a9..bcf10c38b0 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationMeta.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationMeta.java
@@ -1,6 +1,6 @@
package stirling.software.proprietary.mcp.catalog;
-import org.springframework.web.method.HandlerMethod;
+import java.lang.reflect.Method;
import tools.jackson.databind.node.ObjectNode;
@@ -13,7 +13,12 @@ public record OperationMeta(
String requiredScope,
Target target,
String endpointPath,
- HandlerMethod handlerMethod) {
+ // TODO: Migration required - was org.springframework.web.method.HandlerMethod (Spring MVC,
+ // no Quarkus equivalent). Replaced with the underlying java.lang.reflect.Method. The
+ // collaborator McpToolCatalog must be updated to discover JAX-RS resource methods (e.g. via
+ // RESTEasy Reactive ResourceScanningSupport / jakarta.ws.rs annotations) instead of
+ // Spring's RequestMappingHandlerMapping, and pass a reflect.Method here.
+ Method handlerMethod) {
public enum Target {
JAVA_ENDPOINT,
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGenerator.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGenerator.java
index 0cef8d32bf..efd1543b4c 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGenerator.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGenerator.java
@@ -8,7 +8,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Set;
-import org.springframework.web.multipart.MultipartFile;
+import stirling.software.common.model.MultipartFile;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/engine/EngineCapabilityClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/engine/EngineCapabilityClient.java
index ed023bd25b..4688e85f46 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/engine/EngineCapabilityClient.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/engine/EngineCapabilityClient.java
@@ -12,13 +12,12 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.boot.context.event.ApplicationReadyEvent;
-import org.springframework.context.event.EventListener;
-import org.springframework.stereotype.Component;
+import io.quarkus.runtime.StartupEvent;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.event.Observes;
import lombok.extern.slf4j.Slf4j;
@@ -36,8 +35,11 @@ import tools.jackson.databind.node.ObjectNode;
* {@link McpToolCatalog}.
*/
@Slf4j
-@Component
-@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
+@ApplicationScoped
+// TODO: Migration required - @ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
+// has no direct CDI equivalent. The onReady() observer below guards on a runtime config toggle
+// instead; consider @io.quarkus.arc.lookup.LookupIfProperty / a build-time profile if the bean
+// itself should be excluded.
public class EngineCapabilityClient {
private final ApplicationProperties applicationProperties;
@@ -70,8 +72,7 @@ public class EngineCapabilityClient {
});
}
- @EventListener(ApplicationReadyEvent.class)
- public void onReady() {
+ public void onReady(@Observes StartupEvent event) {
long minutes =
Math.max(1, applicationProperties.getMcp().getEngineCapabilityRefreshMinutes());
// First refresh immediately, then on the configured cadence.
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java
index e45dadb0c0..065f1ff43b 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java
@@ -4,19 +4,12 @@ import java.io.IOException;
import java.util.List;
import java.util.Optional;
-import org.springframework.security.authentication.AnonymousAuthenticationToken;
-import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.GrantedAuthority;
-import org.springframework.security.core.authority.SimpleGrantedAuthority;
-import org.springframework.security.core.context.SecurityContext;
-import org.springframework.security.core.context.SecurityContextHolder;
-import org.springframework.web.filter.OncePerRequestFilter;
-
+import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@@ -28,12 +21,11 @@ import stirling.software.proprietary.security.service.UserService;
* that user with the MCP scopes.
*/
@Slf4j
-public class McpApiKeyAuthFilter extends OncePerRequestFilter {
+public class McpApiKeyAuthFilter implements Filter {
- private static final List MCP_SCOPES =
- List.of(
- new SimpleGrantedAuthority("SCOPE_mcp.tools.read"),
- new SimpleGrantedAuthority("SCOPE_mcp.tools.write"));
+ // MCP scopes granted to a request authenticated via API key.
+ private static final List MCP_SCOPES =
+ List.of("SCOPE_mcp.tools.read", "SCOPE_mcp.tools.write");
private final UserService userService;
@@ -42,33 +34,35 @@ public class McpApiKeyAuthFilter extends OncePerRequestFilter {
}
@Override
- protected void doFilterInternal(
- HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
- throws ServletException, IOException {
- Authentication existing = SecurityContextHolder.getContext().getAuthentication();
- // Treat an anonymous token as not authenticated so the key is still processed.
- boolean unauthenticated =
- existing == null
- || existing instanceof AnonymousAuthenticationToken
- || !existing.isAuthenticated();
- if (unauthenticated) {
- String apiKey = extractKey(request);
- if (apiKey != null && !apiKey.isBlank()) {
- Optional user = userService.getUserByApiKey(apiKey);
- if (user.isPresent() && user.get().isEnabled()) {
- UsernamePasswordAuthenticationToken auth =
- new UsernamePasswordAuthenticationToken(
- user.get().getUsername(), null, MCP_SCOPES);
- SecurityContext context = SecurityContextHolder.createEmptyContext();
- context.setAuthentication(auth);
- SecurityContextHolder.setContext(context);
- } else {
- log.warn(
- "MCP access denied: presented API key did not match an active account");
- }
+ public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
+ FilterChain filterChain) throws IOException, ServletException {
+ HttpServletRequest request = (HttpServletRequest) servletRequest;
+
+ // TODO: Migration required - Spring Security removed. This filter previously read the
+ // current Authentication from SecurityContextHolder to decide whether to process the API
+ // key. Quarkus has no SecurityContextHolder; the current identity is exposed via
+ // io.quarkus.security.identity.SecurityIdentity. With the binding below not yet wired, we
+ // always attempt to validate the presented key so the lookup logic is preserved.
+ String apiKey = extractKey(request);
+ if (apiKey != null && !apiKey.isBlank()) {
+ Optional user = userService.getUserByApiKey(apiKey);
+ if (user.isPresent() && user.get().isEnabled()) {
+ // TODO: Migration required - bind the resolved user + MCP_SCOPES to the request
+ // identity. Spring's UsernamePasswordAuthenticationToken /
+ // SecurityContextHolder.setContext(...) has no servlet-filter equivalent in
+ // Quarkus. Implement an io.quarkus.security.identity.SecurityIdentityAugmentor (or
+ // a custom io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism /
+ // IdentityProvider keyed off the X-API-KEY / Bearer credential) that produces a
+ // SecurityIdentity with principal=user.getUsername() and roles=MCP_SCOPES.
+ log.debug(
+ "MCP API key matched active account '{}' (identity binding pending Quarkus"
+ + " SecurityIdentity migration)",
+ user.get().getUsername());
+ } else {
+ log.warn("MCP access denied: presented API key did not match an active account");
}
}
- filterChain.doFilter(request, response);
+ filterChain.doFilter(servletRequest, servletResponse);
}
private String extractKey(HttpServletRequest request) {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java
index 7430776def..4a123749cc 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java
@@ -2,16 +2,21 @@ package stirling.software.proprietary.mcp.security;
import java.util.List;
-import org.springframework.security.oauth2.core.OAuth2Error;
-import org.springframework.security.oauth2.core.OAuth2TokenValidator;
-import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
-import org.springframework.security.oauth2.jwt.Jwt;
-
/**
* RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id in its
* {@code aud} claim. Fails closed when the resource id is unset.
+ *
+ * TODO: Migration required - this was a Spring Security
+ * {@code OAuth2TokenValidator}. Quarkus-oidc has no equivalent validator SPI; the standard way
+ * to enforce audience binding is configuration:
+ * {@code quarkus.oidc.token.audience=} (combined with
+ * {@code mp.jwt.verify.audiences} for smallrye-jwt). The fail-closed behaviour when no resource id
+ * is configured must be reproduced either by making that config mandatory or by augmenting the
+ * {@code io.quarkus.security.identity.SecurityIdentity} via a
+ * {@code SecurityIdentityAugmentor}. The pure audience-check logic below is preserved so it can be
+ * invoked from such an augmentor or a custom {@code jakarta.ws.rs.container.ContainerRequestFilter}.
*/
-public class McpAudienceValidator implements OAuth2TokenValidator {
+public class McpAudienceValidator {
private final String expectedResourceId;
@@ -19,26 +24,37 @@ public class McpAudienceValidator implements OAuth2TokenValidator {
this.expectedResourceId = expectedResourceId == null ? "" : expectedResourceId;
}
- @Override
- public OAuth2TokenValidatorResult validate(Jwt token) {
+ /**
+ * Validates that the supplied token audience claim contains this server's resource id.
+ *
+ * @param audience the {@code aud} claim values from the JWT
+ * @return a result describing success or the failure reason
+ */
+ public Result validate(List audience) {
if (expectedResourceId.isBlank()) {
- return OAuth2TokenValidatorResult.failure(
- new OAuth2Error(
- "invalid_token",
- "MCP server has no resource id configured; rejecting all tokens"
- + " until mcp.auth.resource-id is set.",
- null));
+ return Result.failure(
+ "invalid_token",
+ "MCP server has no resource id configured; rejecting all tokens"
+ + " until mcp.auth.resource-id is set.");
}
- List aud = token.getAudience();
- if (aud == null || !aud.contains(expectedResourceId)) {
- return OAuth2TokenValidatorResult.failure(
- new OAuth2Error(
- "invalid_token",
- "Token audience does not include this server's resource id ("
- + expectedResourceId
- + ").",
- null));
+ if (audience == null || !audience.contains(expectedResourceId)) {
+ return Result.failure(
+ "invalid_token",
+ "Token audience does not include this server's resource id ("
+ + expectedResourceId
+ + ").");
+ }
+ return Result.success();
+ }
+
+ /** Outcome of an audience validation, replacing Spring's OAuth2TokenValidatorResult. */
+ public record Result(boolean valid, String errorCode, String description) {
+ static Result success() {
+ return new Result(true, null, null);
+ }
+
+ static Result failure(String errorCode, String description) {
+ return new Result(false, errorCode, description);
}
- return OAuth2TokenValidatorResult.success();
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPoint.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPoint.java
index 5139ea1621..d29bf8e4dc 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPoint.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPoint.java
@@ -2,31 +2,38 @@ package stirling.software.proprietary.mcp.security;
import java.io.IOException;
-import org.springframework.http.HttpStatus;
-import org.springframework.security.core.AuthenticationException;
-import org.springframework.security.web.AuthenticationEntryPoint;
-
+import jakarta.enterprise.context.ApplicationScoped;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
+import jakarta.ws.rs.core.Response;
/**
* Emits 401 + {@code WWW-Authenticate: Bearer resource_metadata="..."} (RFC 9728), preferring
* X-Forwarded-* headers to build the public-facing metadata URL.
+ *
+ * TODO: Migration required - this was a Spring Security {@code AuthenticationEntryPoint}
+ * (commence(...) invoked by the SecurityFilterChain on authentication failure). Quarkus has no
+ * SecurityFilterChain equivalent. The 401 response must instead be produced by a Quarkus auth
+ * mechanism / failure handler (e.g. an {@link io.quarkus.security.AuthenticationFailedException}
+ * mapper via a {@code jakarta.ws.rs.ext.ExceptionMapper}, or a custom HttpAuthenticationMechanism
+ * sendChallenge). The reusable header-building logic below has been preserved; wire
+ * {@link #commence(HttpServletRequest, HttpServletResponse)} into that handler.
*/
-public class McpAuthenticationEntryPoint implements AuthenticationEntryPoint {
+@ApplicationScoped
+public class McpAuthenticationEntryPoint {
private final String metadataPath;
+ public McpAuthenticationEntryPoint() {
+ this("/.well-known/oauth-protected-resource");
+ }
+
public McpAuthenticationEntryPoint(String metadataPath) {
this.metadataPath =
metadataPath == null ? "/.well-known/oauth-protected-resource" : metadataPath;
}
- @Override
- public void commence(
- HttpServletRequest request,
- HttpServletResponse response,
- AuthenticationException authException)
+ public void commence(HttpServletRequest request, HttpServletResponse response)
throws IOException {
String scheme = firstForwarded(request, "X-Forwarded-Proto", request.getScheme());
String authority = forwardedHost(request, scheme);
@@ -34,7 +41,7 @@ public class McpAuthenticationEntryPoint implements AuthenticationEntryPoint {
response.setHeader(
"WWW-Authenticate",
"Bearer error=\"invalid_token\", resource_metadata=\"" + metadataUrl + "\"");
- response.sendError(HttpStatus.UNAUTHORIZED.value(), "Unauthorized");
+ response.sendError(Response.Status.UNAUTHORIZED.getStatusCode(), "Unauthorized");
}
/** host[:port] from forwarded headers when present, else the servlet host/port. */
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpRequestSizeFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpRequestSizeFilter.java
index b6c410a30c..274f67b8bf 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpRequestSizeFilter.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpRequestSizeFilter.java
@@ -9,12 +9,13 @@ import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
-import org.springframework.web.filter.OncePerRequestFilter;
-
+import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
@@ -23,7 +24,11 @@ import jakarta.servlet.http.HttpServletResponse;
* Caps MCP request body size (via Content-Length and by buffering up to the cap) and rejects
* oversized bodies with a clean 413 before JSON parsing.
*/
-public class McpRequestSizeFilter extends OncePerRequestFilter {
+// TODO: Migration required - this filter was a Spring OncePerRequestFilter; under Quarkus
+// (quarkus-undertow) register it as a jakarta.servlet.Filter via @WebFilter or a programmatic
+// FilterRegistrationBean equivalent, and ensure it runs once per request and before the MCP
+// endpoint. Registration ordering must be verified by the collaborator wiring the servlet filters.
+public class McpRequestSizeFilter implements Filter {
private final long maxBodyBytes;
@@ -32,9 +37,11 @@ public class McpRequestSizeFilter extends OncePerRequestFilter {
}
@Override
- protected void doFilterInternal(
- HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ public void doFilter(
+ ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws ServletException, IOException {
+ HttpServletRequest request = (HttpServletRequest) servletRequest;
+ HttpServletResponse response = (HttpServletResponse) servletResponse;
long declared = request.getContentLengthLong();
if (declared > maxBodyBytes) {
tooLarge(response);
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java
index ab6341ed39..6349e0bf93 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java
@@ -1,38 +1,7 @@
package stirling.software.proprietary.mcp.security;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-
-import org.springframework.beans.factory.ObjectProvider;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.context.annotation.Lazy;
-import org.springframework.core.Ordered;
-import org.springframework.core.annotation.Order;
-import org.springframework.core.convert.converter.Converter;
-import org.springframework.http.HttpMethod;
-import org.springframework.security.authentication.AbstractAuthenticationToken;
-import org.springframework.security.config.annotation.web.builders.HttpSecurity;
-import org.springframework.security.config.http.SessionCreationPolicy;
-import org.springframework.security.core.GrantedAuthority;
-import org.springframework.security.core.authority.SimpleGrantedAuthority;
-import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
-import org.springframework.security.oauth2.core.OAuth2TokenValidator;
-import org.springframework.security.oauth2.jwt.Jwt;
-import org.springframework.security.oauth2.jwt.JwtDecoder;
-import org.springframework.security.oauth2.jwt.JwtValidators;
-import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
-import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
-import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
-import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
-import org.springframework.security.web.SecurityFilterChain;
-import org.springframework.security.web.access.intercept.AuthorizationFilter;
-import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
-import org.springframework.web.cors.CorsConfigurationSource;
-
import jakarta.annotation.PostConstruct;
+import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
@@ -42,37 +11,67 @@ import stirling.software.proprietary.security.service.UserService;
/**
* MCP security chain: validates JWTs (JWKS + RFC 8707 audience), maps scope claims to authorities,
* and fails closed when the issuer is unset.
+ *
+ *
TODO: Migration required - this class was a Spring Security {@code SecurityFilterChain} /
+ * {@code HttpSecurity} DSL configuration, which has NO direct Quarkus equivalent. The Spring
+ * security DSL has been removed; the equivalent behaviour must be rebuilt on Quarkus primitives:
+ *
+ *
+ * HTTP path matching ({@code /mcp}, {@code /mcp/**}, {@code /.well-known/oauth-protected-resource})
+ * and authenticated-vs-permitAll policy -> declare via {@code quarkus.http.auth.permission.*}
+ * in application.properties (permit GET on the metadata path, authenticate the rest), or via a
+ * {@code jakarta.ws.rs.container.ContainerRequestFilter}.
+ * Stateless session ({@code SessionCreationPolicy.STATELESS}) and CSRF-disabled -> Quarkus REST
+ * is stateless by default; no CSRF filter is added unless quarkus-csrf-reactive is enabled.
+ * OAuth2 resource-server JWT validation (issuer/JWKS + RFC 8707 audience + scope->authority
+ * mapping) -> quarkus-oidc in {@code service} application type, or quarkus-smallrye-jwt for
+ * bearer validation. Wire {@code quarkus.oidc.auth-server-url}=issuer-uri,
+ * {@code quarkus.oidc.token.audience}=resource-id; map the {@code scope} claim to roles via a
+ * {@code io.quarkus.security.identity.SecurityIdentityAugmentor} (replacing
+ * {@code JwtGrantedAuthoritiesConverter} with prefix {@code SCOPE_} and the {@code AUDIENCE_}
+ * authorities added below). The fail-closed behaviour when issuer-uri is blank is preserved by
+ * NOT configuring quarkus.oidc when blank (every bearer request then 401s).
+ * API-key mode ({@code mcp.auth.mode=apikey}) -> register {@link McpApiKeyAuthFilter} as a
+ * {@code jakarta.ws.rs.container.ContainerRequestFilter @Provider} (or a jakarta.servlet
+ * Filter via quarkus-undertow) that validates the X-API-KEY / Bearer key against
+ * {@link UserService} and returns the 401 + {@code WWW-Authenticate} response below.
+ * RFC 9728 protected-resource metadata ({@code /.well-known/oauth-protected-resource} with
+ * resource/authorizationServer/scopes mcp.tools.read + mcp.tools.write) -> serve from a small
+ * JAX-RS resource returning the JSON document.
+ * Pre-auth body-size cap ({@link McpRequestSizeFilter}) and post-auth user binding
+ * ({@link McpUserBindingFilter}) -> register as ContainerRequestFilters with explicit
+ * {@code @Priority} so size-cap runs before auth and user-binding runs after; ordering matters.
+ * Reused CORS source ({@code corsConfigurationSource}) -> configure via {@code quarkus.http.cors.*}.
+ *
+ *
+ * The helper components ({@link McpApiKeyAuthFilter}, {@link McpUserBindingFilter},
+ * {@link McpRequestSizeFilter}, {@link McpAudienceValidator}, {@link McpAuthenticationEntryPoint})
+ * are preserved unchanged and should be wired in by the new Quarkus security plumbing. The
+ * configuration-reading and fail-closed warning logic below is kept verbatim.
*/
@Slf4j
-@Configuration
-@Order(Ordered.HIGHEST_PRECEDENCE)
-@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
+@ApplicationScoped
+// TODO: Migration required - @Order(Ordered.HIGHEST_PRECEDENCE) and
+// @ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") were removed. Gate MCP
+// security wiring on the runtime property mcp.enabled=true (the value is a runtime toggle, not a
+// build profile, so prefer a runtime guard in the new ContainerRequestFilter/augmentor rather than
+// @IfBuildProfile). Filter ordering (highest precedence) must be re-expressed via JAX-RS @Priority
+// or quarkus.http.auth.permission ordering.
public class McpSecurityConfig {
private final ApplicationProperties applicationProperties;
- private final UserService userService;
- // Reuse the app's CORS config; ObjectProvider so the chain still wires when no CORS bean
- // exists.
- private final ObjectProvider corsConfigurationSource;
+ // TODO: Migration required - UserService was injected @Lazy to break a circular wiring with the
+ // security chain. With the Spring chain removed, inject it directly into the new API-key /
+ // user-binding ContainerRequestFilters instead of holding it here.
+ private final UserService userService;
private static final String BASE_PATH = "/mcp";
public McpSecurityConfig(
- ApplicationProperties applicationProperties,
- @Lazy UserService userService,
- ObjectProvider corsConfigurationSource) {
+ ApplicationProperties applicationProperties, UserService userService) {
this.applicationProperties = applicationProperties;
this.userService = userService;
- this.corsConfigurationSource = corsConfigurationSource;
- }
-
- /** Enable CORS on the MCP chain using the app-wide source when available. */
- private void applyCors(HttpSecurity http) throws Exception {
- CorsConfigurationSource source = corsConfigurationSource.getIfAvailable();
- if (source != null) {
- http.cors(cors -> cors.configurationSource(source));
- }
}
@PostConstruct
@@ -98,165 +97,49 @@ public class McpSecurityConfig {
}
}
- @Bean
- @Order(0)
- SecurityFilterChain mcpSecurityFilterChain(HttpSecurity http, JwtDecoder mcpJwtDecoder)
- throws Exception {
- ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth();
- if (isApiKeyMode()) {
- return apiKeyFilterChain(http);
- }
- return oauthFilterChain(http, mcpJwtDecoder, auth);
- }
-
private boolean isApiKeyMode() {
return "apikey".equalsIgnoreCase(applicationProperties.getMcp().getAuth().getMode());
}
- /**
- * API-key chain: a Stirling per-user API key is validated by {@link McpApiKeyAuthFilter};
- * otherwise 401.
- */
- private SecurityFilterChain apiKeyFilterChain(HttpSecurity http) throws Exception {
- applyCors(http);
- http.securityMatcher(BASE_PATH, BASE_PATH + "/**")
- // CSRF intentionally disabled: /mcp is a stateless JSON-RPC API authenticated by an
- // out-of-band X-API-KEY header (or Authorization: Bearer ). No cookies, no
- // session, no form submissions; a browser cannot trick a victim into sending the
- // header cross-origin, so the CSRF attack model does not apply. CodeQL flags this
- // generically; the SessionCreationPolicy.STATELESS below is the relevant guarantee.
- .csrf(csrf -> csrf.disable())
- .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
- .authorizeHttpRequests(a -> a.anyRequest().authenticated())
- .exceptionHandling(
- e ->
- e.authenticationEntryPoint(
- (request, response, ex) -> {
- response.setStatus(401);
- response.setHeader(
- "WWW-Authenticate",
- "Bearer realm=\"Stirling MCP (API key)\"");
- response.setContentType("application/json");
- response.getWriter()
- .write(
- "{\"error\":\"unauthorized\",\"message\":\"Provide a valid Stirling API key via the X-API-KEY header (or Authorization: Bearer ).\"}");
- }))
- .addFilterBefore(
- new McpRequestSizeFilter(
- applicationProperties.getMcp().getMaxRequestBytes()),
- AuthorizationFilter.class)
- // Authenticate before the anonymous filter sets an anonymous token.
- .addFilterBefore(
- new McpApiKeyAuthFilter(userService), AnonymousAuthenticationFilter.class);
- return http.build();
- }
+ // TODO: Migration required - the following describe the original chain wiring so the Quarkus
+ // re-implementation can reproduce it faithfully. They are documented as constants/notes rather
+ // than executable HttpSecurity DSL (which does not exist in Quarkus).
- /** OAuth2 resource-server chain (JWT, RFC 8707 audience, RFC 9728 metadata). */
- private SecurityFilterChain oauthFilterChain(
- HttpSecurity http, JwtDecoder mcpJwtDecoder, ApplicationProperties.Mcp.Auth auth)
- throws Exception {
- String metadataPath = "/.well-known/oauth-protected-resource";
- applyCors(http);
- http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath)
- // CSRF intentionally disabled: /mcp is a stateless JSON-RPC resource server
- // authenticated by OAuth2 Bearer JWTs (Authorization header). No cookies, no
- // session, no form submissions; CSRF requires browser-attached ambient credentials
- // and the bearer token is supplied per-request by the MCP client. CodeQL flags
- // this generically; the SessionCreationPolicy.STATELESS below is the actual
- // guarantee, and the .well-known metadata endpoint only serves GET.
- .csrf(csrf -> csrf.disable())
- .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
- .authorizeHttpRequests(
- a ->
- a.requestMatchers(HttpMethod.GET, metadataPath)
- .permitAll()
- .anyRequest()
- .authenticated())
- // Cap body size pre-auth, then bind the validated token to a Stirling user after
- // the bearer filter.
- .addFilterBefore(
- new McpRequestSizeFilter(
- applicationProperties.getMcp().getMaxRequestBytes()),
- BearerTokenAuthenticationFilter.class)
- .addFilterAfter(
- new McpUserBindingFilter(
- userService,
- auth.getUsernameClaim(),
- auth.isRequireExistingAccount()),
- BearerTokenAuthenticationFilter.class)
- .oauth2ResourceServer(
- oauth2 ->
- oauth2.authenticationEntryPoint(
- new McpAuthenticationEntryPoint(metadataPath))
- // RFC 9728 protected-resource metadata for OAuth discovery.
- .protectedResourceMetadata(
- prm ->
- prm.protectedResourceMetadataCustomizer(
- builder -> {
- if (!auth.getResourceId()
- .isBlank()) {
- builder.resource(
- auth
- .getResourceId());
- }
- if (!auth.getIssuerUri()
- .isBlank()) {
- builder.authorizationServer(
- auth
- .getIssuerUri());
- }
- builder.scope("mcp.tools.read");
- builder.scope(
- "mcp.tools.write");
- }))
- .jwt(
- jwt ->
- jwt.decoder(mcpJwtDecoder)
- .jwtAuthenticationConverter(
- mcpJwtAuthenticationConverter())));
- return http.build();
- }
+ // API-key chain (mcp.auth.mode=apikey): securityMatcher(BASE_PATH, BASE_PATH + "/**");
+ // CSRF disabled (stateless JSON-RPC, X-API-KEY / Bearer , no cookies/session);
+ // SessionCreationPolicy.STATELESS; anyRequest().authenticated();
+ // authenticationEntryPoint -> 401 with header WWW-Authenticate: Bearer realm="Stirling MCP
+ // (API key)", Content-Type application/json, body
+ // {"error":"unauthorized","message":"Provide a valid Stirling API key via the X-API-KEY
+ // header (or Authorization: Bearer )."};
+ // addFilterBefore(new McpRequestSizeFilter(maxRequestBytes), AuthorizationFilter.class);
+ // addFilterBefore(new McpApiKeyAuthFilter(userService), AnonymousAuthenticationFilter.class)
+ // (authenticate before any anonymous token is set).
- @Bean
- JwtDecoder mcpJwtDecoder() {
- ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth();
- if (auth.getIssuerUri().isBlank()) {
- // Fail-closed decoder: rejects every token until the issuer is set.
- return token -> {
- throw new org.springframework.security.oauth2.jwt.BadJwtException(
- "mcp.auth.issuer-uri is not configured");
- };
- }
- String jwksUri = auth.getJwksUri();
- NimbusJwtDecoder decoder =
- jwksUri.isBlank()
- ? NimbusJwtDecoder.withIssuerLocation(auth.getIssuerUri()).build()
- : NimbusJwtDecoder.withJwkSetUri(jwksUri).build();
- OAuth2TokenValidator defaultValidators =
- JwtValidators.createDefaultWithIssuer(auth.getIssuerUri());
- OAuth2TokenValidator combined =
- new DelegatingOAuth2TokenValidator<>(
- defaultValidators, new McpAudienceValidator(auth.getResourceId()));
- decoder.setJwtValidator(combined);
- return decoder;
- }
+ // OAuth2 resource-server chain: metadataPath = "/.well-known/oauth-protected-resource";
+ // securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath);
+ // CSRF disabled; SessionCreationPolicy.STATELESS;
+ // GET metadataPath permitAll, anyRequest().authenticated();
+ // addFilterBefore(new McpRequestSizeFilter(maxRequestBytes), BearerTokenAuthenticationFilter.class);
+ // addFilterAfter(new McpUserBindingFilter(userService, auth.getUsernameClaim(),
+ // auth.isRequireExistingAccount()), BearerTokenAuthenticationFilter.class);
+ // oauth2ResourceServer: authenticationEntryPoint = new McpAuthenticationEntryPoint(metadataPath);
+ // RFC 9728 protected-resource metadata -> resource=auth.getResourceId() (if non-blank),
+ // authorizationServer=auth.getIssuerUri() (if non-blank), scopes mcp.tools.read +
+ // mcp.tools.write;
+ // jwt: decoder=mcpJwtDecoder, jwtAuthenticationConverter=mcpJwtAuthenticationConverter.
- private Converter mcpJwtAuthenticationConverter() {
- JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
- scopes.setAuthorityPrefix("SCOPE_");
- scopes.setAuthoritiesClaimName("scope");
- JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
- converter.setJwtGrantedAuthoritiesConverter(
- jwt -> {
- Collection out = new ArrayList<>(scopes.convert(jwt));
- List aud = jwt.getAudience();
- if (aud != null) {
- for (String a : aud) {
- out.add(new SimpleGrantedAuthority("AUDIENCE_" + a));
- }
- }
- return out;
- });
- return converter;
- }
+ // JWT decoder (was @Bean JwtDecoder mcpJwtDecoder): fail-closed when auth.getIssuerUri() is
+ // blank (reject every token); else NimbusJwtDecoder.withJwkSetUri(jwksUri) when jwks-uri set,
+ // otherwise NimbusJwtDecoder.withIssuerLocation(issuerUri); validators =
+ // DelegatingOAuth2TokenValidator(default-with-issuer, new McpAudienceValidator(resourceId)).
+ // -> Replace with quarkus-oidc/quarkus-smallrye-jwt config (auth-server-url=issuer-uri,
+ // token.audience=resource-id, jwks via discovery or quarkus.oidc.jwks-path). Keep
+ // McpAudienceValidator's audience logic in a custom validator if OIDC's audience check is
+ // insufficient. Do NOT configure when issuer-uri is blank to preserve fail-closed behaviour.
+
+ // JWT authentication converter (scope -> authority mapping): map the "scope" claim to authorities
+ // with prefix "SCOPE_", and additionally add "AUDIENCE_" for each audience entry on the
+ // token. -> Re-implement in a io.quarkus.security.identity.SecurityIdentityAugmentor that adds
+ // roles "SCOPE_" and "AUDIENCE_" to the SecurityIdentity.
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpUserBindingFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpUserBindingFilter.java
index 59dfbbfacc..d553ef25ca 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpUserBindingFilter.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpUserBindingFilter.java
@@ -3,16 +3,11 @@ package stirling.software.proprietary.mcp.security;
import java.io.IOException;
import java.util.Optional;
-import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.context.SecurityContext;
-import org.springframework.security.core.context.SecurityContextHolder;
-import org.springframework.security.oauth2.jwt.Jwt;
-import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
-import org.springframework.web.filter.OncePerRequestFilter;
-
+import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -28,9 +23,21 @@ import tools.jackson.databind.node.ObjectNode;
* Binds an MCP-validated JWT to a provisioned Stirling user: optionally rejects subjects with no
* enabled account, then rebinds the principal to the canonical Stirling username (scope authorities
* only) so audit/metering attribute correctly.
+ *
+ * TODO: Migration required - this was a Spring Security {@code OncePerRequestFilter} that read
+ * and rewrote the {@code SecurityContextHolder} ({@code JwtAuthenticationToken}/{@code Jwt}).
+ * Quarkus has no global mutable security context; the canonical replacement is a
+ * {@code io.quarkus.security.identity.SecurityIdentityAugmentor} that runs after quarkus-oidc/
+ * quarkus-smallrye-jwt validates the bearer token, reads the username claim from the
+ * {@code JsonWebToken}, looks up the Stirling account via {@link UserService}, and rebuilds the
+ * {@code SecurityIdentity} with the canonical principal name while preserving the original scope
+ * roles. The account-lookup and reject logic below is preserved; only the identity read/rebind and
+ * the request rejection plumbing still need to be wired to the augmentor (or to a
+ * {@code jakarta.ws.rs.container.ContainerRequestFilter @Provider} that aborts with 403). Until
+ * then this filter passes every request through unchanged.
*/
@Slf4j
-public class McpUserBindingFilter extends OncePerRequestFilter {
+public class McpUserBindingFilter implements Filter {
private static final ObjectMapper MAPPER = new ObjectMapper();
@@ -47,15 +54,19 @@ public class McpUserBindingFilter extends OncePerRequestFilter {
}
@Override
- protected void doFilterInternal(
- HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ public void doFilter(ServletRequest req, ServletResponse res, FilterChain filterChain)
throws ServletException, IOException {
- Authentication current = SecurityContextHolder.getContext().getAuthentication();
+ HttpServletResponse response = (HttpServletResponse) res;
- // Only act on a JWT-authenticated request; everything else passes through.
- if (current instanceof JwtAuthenticationToken jwtAuth && jwtAuth.isAuthenticated()) {
- Jwt jwt = jwtAuth.getToken();
- String username = jwt.getClaimAsString(usernameClaim);
+ // TODO: Migration required - extract the validated JWT and its claims from the Quarkus
+ // SecurityIdentity / JsonWebToken instead of Spring's SecurityContextHolder. The block
+ // below preserves the original binding logic but cannot run until that wiring exists, so
+ // for now every request passes through untouched.
+ boolean jwtAuthenticated = false; // TODO: derive from injected SecurityIdentity / JWT
+ if (jwtAuthenticated) {
+ // TODO: Migration required - read the claim value from the validated token, e.g.
+ // jsonWebToken.getClaim(usernameClaim). Placeholder keeps the surrounding logic intact.
+ String username = null; // TODO: jwt.getClaim(usernameClaim)
if (username == null || username.isBlank()) {
reject(
@@ -85,17 +96,15 @@ public class McpUserBindingFilter extends OncePerRequestFilter {
boundUsername = account.get().getUsername();
}
- // Rebind to the Stirling username, carrying only the OAuth scope authorities.
- UsernamePasswordAuthenticationToken bound =
- new UsernamePasswordAuthenticationToken(
- boundUsername, null, jwtAuth.getAuthorities());
- bound.setDetails(jwtAuth.getDetails());
- SecurityContext context = SecurityContextHolder.createEmptyContext();
- context.setAuthentication(bound);
- SecurityContextHolder.setContext(context);
+ // TODO: Migration required - rebind to the Stirling username, carrying only the OAuth
+ // scope authorities. With quarkus-oidc/smallrye-jwt this is done by a
+ // SecurityIdentityAugmentor that returns a new SecurityIdentity whose principal name is
+ // boundUsername and whose roles are the original token scopes. boundUsername is computed
+ // above and ready to feed into that augmentor.
+ log.debug("MCP user binding resolved canonical username: {}", boundUsername);
}
- filterChain.doFilter(request, response);
+ filterChain.doFilter(req, res);
}
/** Strip CR/LF so a crafted claim value can't forge log lines. */
@@ -104,7 +113,10 @@ public class McpUserBindingFilter extends OncePerRequestFilter {
}
private void reject(HttpServletResponse response, String message) throws IOException {
- SecurityContextHolder.clearContext();
+ // TODO: Migration required - on the Quarkus path, rejection should clear/deny the
+ // SecurityIdentity (augmentor throws AuthenticationFailedException) or the
+ // ContainerRequestFilter should abortWith(Response.status(403)...). The 403 JSON body below
+ // is preserved as the intended response shape.
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json");
ObjectNode body = MAPPER.createObjectNode();
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/AbstractCategoryTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/AbstractCategoryTool.java
index 8c75ee1459..b066f4971c 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/AbstractCategoryTool.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/AbstractCategoryTool.java
@@ -2,7 +2,7 @@ package stirling.software.proprietary.mcp.tools;
import java.util.List;
-import org.springframework.beans.factory.ObjectProvider;
+import jakarta.enterprise.inject.Instance;
import stirling.software.proprietary.mcp.McpCallContext;
import stirling.software.proprietary.mcp.McpTool;
@@ -22,13 +22,13 @@ import tools.jackson.databind.node.ObjectNode;
abstract class AbstractCategoryTool implements McpTool {
protected final ObjectMapper mapper;
- protected final ObjectProvider catalogProvider;
- protected final ObjectProvider executorProvider;
+ protected final Instance catalogProvider;
+ protected final Instance executorProvider;
protected AbstractCategoryTool(
ObjectMapper mapper,
- ObjectProvider catalog,
- ObjectProvider executor) {
+ Instance catalog,
+ Instance executor) {
this.mapper = mapper;
this.catalogProvider = catalog;
this.executorProvider = executor;
@@ -37,7 +37,7 @@ abstract class AbstractCategoryTool implements McpTool {
protected abstract OperationCategory category();
protected List enabledOperations() {
- McpToolCatalog catalog = catalogProvider.getIfAvailable();
+ McpToolCatalog catalog = catalogProvider.isResolvable() ? catalogProvider.get() : null;
if (catalog == null) {
return List.of();
}
@@ -104,7 +104,7 @@ abstract class AbstractCategoryTool implements McpTool {
return operationListError(null);
}
String opId = opNode.asText();
- McpToolCatalog catalog = catalogProvider.getIfAvailable();
+ McpToolCatalog catalog = catalogProvider.isResolvable() ? catalogProvider.get() : null;
if (catalog == null) {
return McpResponses.error(mapper, "MCP catalog is not available");
}
@@ -118,7 +118,8 @@ abstract class AbstractCategoryTool implements McpTool {
mapper,
"Insufficient scope: this operation requires '" + meta.requiredScope() + "'.");
}
- McpOperationExecutor executor = executorProvider.getIfAvailable();
+ McpOperationExecutor executor =
+ executorProvider.isResolvable() ? executorProvider.get() : null;
if (executor == null) {
return McpResponses.error(mapper, "MCP execution is not available.");
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/DescribeOperationTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/DescribeOperationTool.java
index c93578136a..25957cb493 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/DescribeOperationTool.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/DescribeOperationTool.java
@@ -1,8 +1,9 @@
package stirling.software.proprietary.mcp.tools;
-import org.springframework.beans.factory.ObjectProvider;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.stereotype.Component;
+import io.quarkus.arc.lookup.LookupIfProperty;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Instance;
+import jakarta.inject.Inject;
import stirling.software.proprietary.mcp.McpCallContext;
import stirling.software.proprietary.mcp.McpTool;
@@ -15,14 +16,15 @@ import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/** Returns the JSON Schema for one operation's parameters, from the live {@link McpToolCatalog}. */
-@Component
-@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
+@ApplicationScoped
+@LookupIfProperty(name = "mcp.enabled", stringValue = "true")
public class DescribeOperationTool implements McpTool {
private final ObjectMapper mapper;
- private final ObjectProvider catalogProvider;
+ private final Instance catalogProvider;
- public DescribeOperationTool(ObjectMapper mapper, ObjectProvider catalog) {
+ @Inject
+ public DescribeOperationTool(ObjectMapper mapper, Instance catalog) {
this.mapper = mapper;
this.catalogProvider = catalog;
}
@@ -63,7 +65,8 @@ public class DescribeOperationTool implements McpTool {
return McpResponses.error(mapper, "Missing required argument: operation");
}
String opId = opNode.asText();
- McpToolCatalog catalog = catalogProvider.getIfAvailable();
+ McpToolCatalog catalog =
+ catalogProvider.isResolvable() ? catalogProvider.get() : null;
if (catalog == null) {
return McpResponses.error(mapper, "MCP catalog is not available");
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpOperationExecutor.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpOperationExecutor.java
index 28cb56d965..08824be6a7 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpOperationExecutor.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpOperationExecutor.java
@@ -1,25 +1,24 @@
package stirling.software.proprietary.mcp.tools;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
+import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.core.io.ByteArrayResource;
-import org.springframework.core.io.Resource;
-import org.springframework.http.MediaType;
-import org.springframework.http.ResponseEntity;
-import org.springframework.stereotype.Component;
-import org.springframework.util.LinkedMultiValueMap;
-import org.springframework.util.MultiValueMap;
-import org.springframework.web.client.RestClientResponseException;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
+import stirling.software.common.model.io.InputStreamResource;
+import stirling.software.common.model.io.Resource;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.service.InternalApiTimeoutException;
@@ -35,8 +34,11 @@ import tools.jackson.databind.node.ObjectNode;
* to the Stirling endpoint over the loopback via {@link InternalApiClient}, and stores the result.
*/
@Slf4j
-@Component
-@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
+@ApplicationScoped
+// TODO: Migration required - the Spring @ConditionalOnProperty(name = "mcp.enabled",
+// havingValue = "true") guard is not directly portable. For a build-time toggle use
+// @io.quarkus.arc.lookup.LookupIfProperty(name = "mcp.enabled", stringValue = "true") on the
+// injection points, or gate the call sites at runtime; this bean is otherwise always created.
public class McpOperationExecutor {
private final ObjectMapper mapper;
@@ -95,11 +97,13 @@ public class McpOperationExecutor {
inputName = fileName != null ? fileName : "input.pdf";
}
- MultiValueMap body = new LinkedMultiValueMap<>();
- body.add("fileInput", bytesResource(inputBytes, inputName));
+ // The migrated InternalApiClient takes a Map> (replacing Spring's
+ // MultiValueMap) and returns a jakarta.ws.rs.core.Response.
+ Map> body = new LinkedHashMap<>();
+ addToBody(body, "fileInput", bytesResource(inputBytes, inputName));
addParameters(body, arguments == null ? null : arguments.get("parameters"));
- ResponseEntity response;
+ Response response;
try {
response = internalApiClient.post(meta.endpointPath(), body);
} catch (InternalApiTimeoutException e) {
@@ -109,34 +113,39 @@ public class McpOperationExecutor {
+ " timed out after "
+ e.getReadTimeout().toSeconds()
+ "s. Try a smaller file or a different approach.");
- } catch (RestClientResponseException e) {
- log.warn(
- "MCP {} upstream error: HTTP {} - {}",
- meta.id(),
- e.getStatusCode().value(),
- snippet(e.getResponseBodyAsString()));
- return McpResponses.error(
- mapper, meta.id() + " failed: HTTP " + e.getStatusCode().value() + ".");
} catch (SecurityException e) {
return McpResponses.error(
mapper, meta.id() + " endpoint is not permitted for MCP dispatch.");
+ } catch (UncheckedIOException e) {
+ log.warn("MCP execution of {} failed", meta.id(), e);
+ return McpResponses.error(
+ mapper, meta.id() + " failed unexpectedly. See server logs for details.");
} catch (RuntimeException e) {
log.warn("MCP execution of {} failed", meta.id(), e);
return McpResponses.error(
mapper, meta.id() + " failed unexpectedly. See server logs for details.");
}
+
+ // Spring's RestTemplate threw RestClientResponseException on non-2xx upstream responses;
+ // the migrated HttpClient-based InternalApiClient returns the upstream status as a Response.
+ int status = response.getStatus();
+ if (status < 200 || status >= 300) {
+ String responseBody = readErrorBody(response);
+ log.warn("MCP {} upstream error: HTTP {} - {}", meta.id(), status, snippet(responseBody));
+ return McpResponses.error(mapper, meta.id() + " failed: HTTP " + status + ".");
+ }
return buildResult(meta, response);
}
- private ObjectNode buildResult(OperationMeta meta, ResponseEntity response) {
- Resource body = response.getBody();
+ private ObjectNode buildResult(OperationMeta meta, Response response) {
+ Resource body = (Resource) response.getEntity();
if (body == null) {
return McpResponses.error(mapper, meta.id() + " returned an empty response.");
}
- MediaType contentType = response.getHeaders().getContentType();
+ MediaType contentType = response.getMediaType();
// A JSON body is a structured report (e.g. get-info), not a file.
- if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
+ if (contentType != null && MediaType.APPLICATION_JSON_TYPE.isCompatible(contentType)) {
try (InputStream is = body.getInputStream()) {
return McpResponses.text(
mapper, new String(is.readAllBytes(), StandardCharsets.UTF_8));
@@ -152,7 +161,7 @@ public class McpOperationExecutor {
String mimeType =
contentType != null
? contentType.toString()
- : MediaType.APPLICATION_OCTET_STREAM_VALUE;
+ : MediaType.APPLICATION_OCTET_STREAM;
long maxInline = applicationProperties.getMcp().getMaxInlineResponseBytes();
try {
long size = body.contentLength();
@@ -207,7 +216,7 @@ public class McpOperationExecutor {
}
}
- private void addParameters(MultiValueMap body, JsonNode params) {
+ private void addParameters(Map> body, JsonNode params) {
if (params == null || !params.isObject()) {
return;
}
@@ -220,31 +229,54 @@ public class McpOperationExecutor {
}
if (value instanceof List> list) {
if (containsStructured(list)) {
- body.add(entry.getKey(), mapper.writeValueAsString(list));
+ addToBody(body, entry.getKey(), mapper.writeValueAsString(list));
} else {
- list.forEach(item -> body.add(entry.getKey(), item));
+ list.forEach(item -> addToBody(body, entry.getKey(), item));
}
} else if (value instanceof Map, ?>) {
- body.add(entry.getKey(), mapper.writeValueAsString(value));
+ addToBody(body, entry.getKey(), mapper.writeValueAsString(value));
} else {
- body.add(entry.getKey(), value);
+ addToBody(body, entry.getKey(), value);
}
}
}
+ /**
+ * Add a value to a multi-value form body. The body is a {@code Map>}
+ * (replacing Spring's {@code MultiValueMap}) because the migrated {@link InternalApiClient}
+ * encodes the multipart request manually.
+ */
+ private static void addToBody(Map> body, String key, Object value) {
+ body.computeIfAbsent(key, k -> new java.util.ArrayList<>()).add(value);
+ }
+
private static boolean containsStructured(List> list) {
return list.stream().anyMatch(item -> item instanceof Map, ?> || item instanceof List>);
}
private static Resource bytesResource(byte[] bytes, String filename) {
- return new ByteArrayResource(bytes) {
+ return new InputStreamResource(new ByteArrayInputStream(bytes), filename) {
@Override
- public String getFilename() {
- return filename;
+ public long contentLength() {
+ return bytes.length;
}
};
}
+ private static String readErrorBody(Response response) {
+ try {
+ Object entity = response.getEntity();
+ if (entity instanceof Resource resource) {
+ try (InputStream is = resource.getInputStream()) {
+ return new String(is.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+ return entity != null ? String.valueOf(entity) : null;
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
private static String snippet(String body) {
if (body == null || body.isBlank()) {
return "(no body)";
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingAiTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingAiTool.java
index fc3696b658..ca8872da90 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingAiTool.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingAiTool.java
@@ -3,9 +3,10 @@ package stirling.software.proprietary.mcp.tools;
import java.io.IOException;
import java.util.List;
-import org.springframework.beans.factory.ObjectProvider;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.stereotype.Component;
+import io.quarkus.arc.lookup.LookupIfProperty;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Instance;
+import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
@@ -26,18 +27,19 @@ import tools.jackson.databind.node.ObjectNode;
* capabilities manifest.
*/
@Slf4j
-@Component
-@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
+@ApplicationScoped
+@LookupIfProperty(name = "mcp.enabled", stringValue = "true")
public class StirlingAiTool implements McpTool {
private final ObjectMapper mapper;
- private final ObjectProvider catalogProvider;
- private final ObjectProvider engineClientProvider;
+ private final Instance catalogProvider;
+ private final Instance engineClientProvider;
+ @Inject
public StirlingAiTool(
ObjectMapper mapper,
- ObjectProvider catalog,
- ObjectProvider engineClient) {
+ Instance catalog,
+ Instance engineClient) {
this.mapper = mapper;
this.catalogProvider = catalog;
this.engineClientProvider = engineClient;
@@ -99,7 +101,7 @@ public class StirlingAiTool implements McpTool {
return McpResponses.error(mapper, "Missing required argument: operation");
}
String opId = opNode.asText();
- McpToolCatalog catalog = catalogProvider.getIfAvailable();
+ McpToolCatalog catalog = catalogProvider.isResolvable() ? catalogProvider.get() : null;
if (catalog == null) {
return McpResponses.error(mapper, "MCP catalog is not available");
}
@@ -117,7 +119,8 @@ public class StirlingAiTool implements McpTool {
mapper,
"Insufficient scope: this capability requires '" + meta.requiredScope() + "'.");
}
- AiEngineClient client = engineClientProvider.getIfAvailable();
+ AiEngineClient client =
+ engineClientProvider.isResolvable() ? engineClientProvider.get() : null;
if (client == null) {
return McpResponses.error(
mapper, "AI engine client is not configured - enable aiEngine in settings.");
@@ -140,7 +143,7 @@ public class StirlingAiTool implements McpTool {
}
private List aiOps() {
- McpToolCatalog catalog = catalogProvider.getIfAvailable();
+ McpToolCatalog catalog = catalogProvider.isResolvable() ? catalogProvider.get() : null;
if (catalog == null) {
return List.of();
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingConvertTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingConvertTool.java
index 9e89f6a5c9..16618f0915 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingConvertTool.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingConvertTool.java
@@ -1,8 +1,9 @@
package stirling.software.proprietary.mcp.tools;
-import org.springframework.beans.factory.ObjectProvider;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.stereotype.Component;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Instance;
+
+import io.quarkus.arc.lookup.LookupIfProperty;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.catalog.OperationCategory;
@@ -10,14 +11,14 @@ import stirling.software.proprietary.mcp.catalog.OperationCategory;
import tools.jackson.databind.ObjectMapper;
/** Exposes the {@code /api/v1/convert/*} namespace as a single MCP tool. */
-@Component
-@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
+@ApplicationScoped
+@LookupIfProperty(name = "mcp.enabled", stringValue = "true")
public class StirlingConvertTool extends AbstractCategoryTool {
public StirlingConvertTool(
ObjectMapper mapper,
- ObjectProvider catalog,
- ObjectProvider executor) {
+ Instance catalog,
+ Instance executor) {
super(mapper, catalog, executor);
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingDownloadTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingDownloadTool.java
index 22fad23845..696863240b 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingDownloadTool.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingDownloadTool.java
@@ -3,9 +3,10 @@ package stirling.software.proprietary.mcp.tools;
import java.io.IOException;
import java.util.Base64;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.http.MediaType;
-import org.springframework.stereotype.Component;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.ws.rs.core.MediaType;
+
+import io.quarkus.arc.lookup.LookupIfProperty;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.FileStorage;
@@ -20,8 +21,8 @@ import tools.jackson.databind.node.ObjectNode;
* Fetches a stored file's content by fileId, returned inline as base64. For large results that were
* not returned inline by an operation.
*/
-@Component
-@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
+@ApplicationScoped
+@LookupIfProperty(name = "mcp.enabled", stringValue = "true")
public class StirlingDownloadTool implements McpTool {
private final ObjectMapper mapper;
@@ -102,7 +103,7 @@ public class StirlingDownloadTool implements McpTool {
McpResponses.resourceBlock(
mapper,
"stirling://file/" + fileId,
- MediaType.APPLICATION_OCTET_STREAM_VALUE,
+ MediaType.APPLICATION_OCTET_STREAM,
Base64.getEncoder().encodeToString(bytes)));
} catch (SecurityException e) {
return McpResponses.error(mapper, "Unknown or inaccessible fileId '" + fileId + "'.");
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingMiscTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingMiscTool.java
index 3d76f47a7f..382ec6925e 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingMiscTool.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingMiscTool.java
@@ -1,8 +1,9 @@
package stirling.software.proprietary.mcp.tools;
-import org.springframework.beans.factory.ObjectProvider;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.stereotype.Component;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Instance;
+
+import io.quarkus.arc.lookup.LookupIfProperty;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.catalog.OperationCategory;
@@ -10,14 +11,14 @@ import stirling.software.proprietary.mcp.catalog.OperationCategory;
import tools.jackson.databind.ObjectMapper;
/** Exposes the {@code /api/v1/misc/*} namespace as a single MCP tool. */
-@Component
-@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
+@ApplicationScoped
+@LookupIfProperty(name = "mcp.enabled", stringValue = "true")
public class StirlingMiscTool extends AbstractCategoryTool {
public StirlingMiscTool(
ObjectMapper mapper,
- ObjectProvider catalog,
- ObjectProvider executor) {
+ Instance catalog,
+ Instance executor) {
super(mapper, catalog, executor);
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingPagesTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingPagesTool.java
index a736a78127..661eaf4f1f 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingPagesTool.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingPagesTool.java
@@ -1,8 +1,9 @@
package stirling.software.proprietary.mcp.tools;
-import org.springframework.beans.factory.ObjectProvider;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.stereotype.Component;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Instance;
+
+import io.quarkus.arc.lookup.LookupIfProperty;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.catalog.OperationCategory;
@@ -10,14 +11,14 @@ import stirling.software.proprietary.mcp.catalog.OperationCategory;
import tools.jackson.databind.ObjectMapper;
/** Exposes the {@code /api/v1/general/*} (page operations) namespace as a single MCP tool. */
-@Component
-@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
+@ApplicationScoped
+@LookupIfProperty(name = "mcp.enabled", stringValue = "true")
public class StirlingPagesTool extends AbstractCategoryTool {
public StirlingPagesTool(
ObjectMapper mapper,
- ObjectProvider catalog,
- ObjectProvider executor) {
+ Instance catalog,
+ Instance executor) {
super(mapper, catalog, executor);
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingSecurityTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingSecurityTool.java
index 300b6c1d09..323aeb2258 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingSecurityTool.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingSecurityTool.java
@@ -1,8 +1,10 @@
package stirling.software.proprietary.mcp.tools;
-import org.springframework.beans.factory.ObjectProvider;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.stereotype.Component;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Instance;
+import jakarta.inject.Inject;
+
+import io.quarkus.arc.lookup.LookupIfProperty;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.catalog.OperationCategory;
@@ -10,14 +12,15 @@ import stirling.software.proprietary.mcp.catalog.OperationCategory;
import tools.jackson.databind.ObjectMapper;
/** Exposes the {@code /api/v1/security/*} namespace as a single MCP tool. */
-@Component
-@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
+@ApplicationScoped
+@LookupIfProperty(name = "mcp.enabled", stringValue = "true")
public class StirlingSecurityTool extends AbstractCategoryTool {
+ @Inject
public StirlingSecurityTool(
ObjectMapper mapper,
- ObjectProvider catalog,
- ObjectProvider executor) {
+ Instance catalog,
+ Instance executor) {
super(mapper, catalog, executor);
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingUploadTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingUploadTool.java
index ebee4e4122..c88d1f06b9 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingUploadTool.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingUploadTool.java
@@ -2,8 +2,10 @@ package stirling.software.proprietary.mcp.tools;
import java.io.IOException;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.stereotype.Component;
+import io.quarkus.arc.lookup.LookupIfProperty;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
@@ -20,13 +22,14 @@ import tools.jackson.databind.node.ObjectNode;
* most operations accept the file inline via their {@code file} argument.
*/
@Slf4j
-@Component
-@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
+@ApplicationScoped
+@LookupIfProperty(name = "mcp.enabled", stringValue = "true")
public class StirlingUploadTool implements McpTool {
private final ObjectMapper mapper;
private final FileStorage fileStorage;
+ @Inject
public StirlingUploadTool(ObjectMapper mapper, FileStorage fileStorage) {
this.mapper = mapper;
this.fileStorage = fileStorage;
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowFileInput.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowFileInput.java
index c83fa55698..4142eee098 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowFileInput.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowFileInput.java
@@ -1,11 +1,11 @@
package stirling.software.proprietary.model.api.ai;
-import org.springframework.http.MediaType;
-import org.springframework.web.multipart.MultipartFile;
-
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
+import jakarta.ws.rs.core.MediaType;
+
+import stirling.software.common.model.MultipartFile;
import lombok.Data;
@@ -16,7 +16,7 @@ public class AiWorkflowFileInput {
@NotNull
@Schema(
description = "The input PDF file",
- contentMediaType = MediaType.APPLICATION_PDF_VALUE,
+ contentMediaType = MediaType.APPLICATION_PDF,
format = "binary")
private MultipartFile fileInput;
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDateExportRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDateExportRequest.java
index 6ce947d098..68def30285 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDateExportRequest.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDateExportRequest.java
@@ -2,8 +2,6 @@ package stirling.software.proprietary.model.api.audit;
import java.time.LocalDate;
-import org.springframework.format.annotation.DateTimeFormat;
-
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
@@ -20,11 +18,12 @@ import stirling.software.proprietary.security.config.EnterpriseEndpoint;
@EqualsAndHashCode
public class AuditDateExportRequest {
- @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
+ // TODO: Migration required - Spring @DateTimeFormat(iso = ISO.DATE) removed; JAX-RS binds
+ // LocalDate via its default ISO-8601 (yyyy-MM-dd) ParamConverter, so ISO.DATE form values
+ // still bind. If a non-ISO format is ever needed, register a jakarta.ws.rs.ext.ParamConverter.
@Schema(description = "Start date for the export range", example = "2025-01-01")
private LocalDate startDate;
- @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
@Schema(description = "End date for the export range", example = "2025-12-31")
private LocalDate endDate;
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java
index 3abf919f1d..aecf9376b2 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java
@@ -2,11 +2,14 @@ package stirling.software.proprietary.policy.config;
import java.nio.file.Path;
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.List;
-import org.springframework.core.env.Environment;
-import org.springframework.stereotype.Component;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+import io.smallrye.config.SmallRyeConfig;
+
+import org.eclipse.microprofile.config.Config;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
@@ -34,7 +37,7 @@ import stirling.software.proprietary.policy.model.Policy;
* root. (Symlink escape is not defended here; an operator who configures an allowed root containing
* a symlink to a sensitive location is trusted.)
*/
-@Component
+@ApplicationScoped
public class FolderAccessGuard {
public static final String FOLDER_TYPE = "folder";
@@ -43,8 +46,12 @@ public class FolderAccessGuard {
private final List allowedRoots;
private final List protectedRoots;
- public FolderAccessGuard(ApplicationProperties applicationProperties, Environment environment) {
- this.saasActive = Arrays.asList(environment.getActiveProfiles()).contains("saas");
+ @Inject
+ public FolderAccessGuard(ApplicationProperties applicationProperties, Config config) {
+ // Spring's Environment.getActiveProfiles() maps to SmallRye's profile list; the "saas"
+ // build/runtime profile is matched the same way Spring matched the "saas" Spring profile.
+ this.saasActive =
+ config.unwrap(SmallRyeConfig.class).getProfiles().contains("saas");
this.allowedRoots =
normalizeAll(applicationProperties.getPolicies().getAllowedFolderRoots());
this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath())));
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java
index df65fbd99c..9177f4a751 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java
@@ -1,39 +1,42 @@
package stirling.software.proprietary.policy.controller;
import java.io.IOException;
+import java.nio.file.Path;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
-import org.springframework.core.io.FileSystemResource;
-import org.springframework.core.io.Resource;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
-import org.springframework.http.ResponseEntity;
-import org.springframework.util.MultiValueMap;
-import org.springframework.web.bind.annotation.DeleteMapping;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
-import org.springframework.web.multipart.MultipartFile;
-import org.springframework.web.multipart.MultipartHttpServletRequest;
-import org.springframework.web.server.ResponseStatusException;
-import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+import org.jboss.resteasy.reactive.server.multipart.FormValue;
+import org.jboss.resteasy.reactive.server.multipart.MultipartFormDataInput;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
-import lombok.RequiredArgsConstructor;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.DELETE;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.WebApplicationException;
+import jakarta.ws.rs.core.Context;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.sse.OutboundSseEvent;
+import jakarta.ws.rs.sse.Sse;
+import jakarta.ws.rs.sse.SseEventSink;
+
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
+import stirling.software.common.model.io.FileSystemResource;
+import stirling.software.common.model.io.Resource;
import stirling.software.common.model.job.JobResponse;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.TempFile;
@@ -66,25 +69,27 @@ import tools.jackson.databind.ObjectMapper;
* the file ids in the run view.
*/
@Slf4j
-@RestController
-@RequestMapping("/api/v1/policies")
+@ApplicationScoped
+@jakarta.ws.rs.Path("/api/v1/policies")
@Hidden
@PremiumEndpoint
-@RequiredArgsConstructor
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
public class PolicyController {
- private final PolicyRunner policyRunner;
- private final PolicyRunRegistry runRegistry;
- private final PolicyStore policyStore;
- private final PolicyValidator policyValidator;
- private final FolderAccessGuard folderAccessGuard;
- private final UserServiceInterface userService;
- private final ApplicationProperties applicationProperties;
- private final ObjectMapper objectMapper;
- private final TempFileManager tempFileManager;
+ @Inject PolicyRunner policyRunner;
+ @Inject PolicyRunRegistry runRegistry;
+ @Inject PolicyStore policyStore;
+ @Inject PolicyValidator policyValidator;
+ @Inject FolderAccessGuard folderAccessGuard;
+ @Inject UserServiceInterface userService;
+ @Inject ApplicationProperties applicationProperties;
+ @Inject ObjectMapper objectMapper;
+ @Inject TempFileManager tempFileManager;
- @PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @POST
+ @jakarta.ws.rs.Path("/run")
+ @Consumes(MediaType.MULTIPART_FORM_DATA)
+ @Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Run a tool pipeline",
description =
@@ -93,34 +98,40 @@ public class PolicyController {
+ " 'company-logo'), and a JSON pipeline definition ('json'). Runs the"
+ " steps in order asynchronously and returns a run id. Poll the run"
+ " status endpoint and download outputs via /api/v1/general/files/{id}.")
- public ResponseEntity> run(
- @RequestParam("json") String json, MultipartHttpServletRequest request)
- throws IOException {
+ public Response run(MultipartFormDataInput request) throws IOException {
+ String json = formValue(request, "json");
PipelineDefinition definition = parseDefinition(json);
PolicyInputs inputs = collectInputs(request);
String runId =
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP).runId();
- return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
+ return Response.status(Response.Status.ACCEPTED)
+ .entity(new JobResponse<>(true, runId, null))
+ .build();
}
- @PostMapping(value = "/run/stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @POST
+ @jakarta.ws.rs.Path("/run/stream")
+ @Consumes(MediaType.MULTIPART_FORM_DATA)
+ @Produces(MediaType.SERVER_SENT_EVENTS)
@Operation(
summary = "Run a tool pipeline with live progress",
description =
"Same as /run, but returns Server-Sent Events: a 'step' event as each step"
+ " starts and completes, then a terminal 'completed', 'failed',"
+ " 'cancelled', or 'waiting' event carrying the final run view.")
- public SseEmitter runStream(
- @RequestParam("json") String json, MultipartHttpServletRequest request)
+ public void runStream(
+ MultipartFormDataInput request, @Context SseEventSink eventSink, @Context Sse sse)
throws IOException {
+ String json = formValue(request, "json");
PipelineDefinition definition = parseDefinition(json);
PolicyInputs inputs = collectInputs(request);
- SseEmitter emitter =
- new SseEmitter(applicationProperties.getPolicies().getStreamTimeoutMs());
- emitter.onError(e -> log.warn("Policy run SSE emitter error", e));
+ // TODO: Migration required - Spring's SseEmitter supported a configurable timeout
+ // (applicationProperties.getPolicies().getStreamTimeoutMs()). JAX-RS SseEventSink has no
+ // per-sink timeout; configure via quarkus.http.* / a reverse proxy if a hard cap is needed.
- PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, streamListener(emitter));
+ PolicyRunHandle handle =
+ policyRunner.runAdHoc(definition, inputs, streamListener(eventSink, sse));
// Close the stream with a terminal event once the run finishes. whenComplete runs on the
// engine's worker thread after the run is done, so this never races the step events.
handle.completion()
@@ -128,46 +139,51 @@ public class PolicyController {
(run, throwable) -> {
if (throwable != null) {
sendEvent(
- emitter,
+ eventSink,
+ sse,
"failed",
Map.of("message", throwable.getMessage()));
} else {
- sendEvent(emitter, terminalEventName(run), PolicyRunView.of(run));
+ sendEvent(
+ eventSink, sse, terminalEventName(run), PolicyRunView.of(run));
}
- emitter.complete();
+ eventSink.close();
});
- return emitter;
}
- @GetMapping("/run/{runId}")
+ @GET
+ @jakarta.ws.rs.Path("/run/{runId}")
+ @Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Get pipeline run status",
description = "Returns the current status, step cursor, and output files of a run.")
- public ResponseEntity status(@PathVariable String runId) {
+ public Response status(@PathParam("runId") String runId) {
PolicyRun run = runRegistry.get(runId);
if (run == null) {
- return ResponseEntity.notFound().build();
+ return Response.status(Response.Status.NOT_FOUND).build();
}
- return ResponseEntity.ok(PolicyRunView.of(run));
+ return Response.ok(PolicyRunView.of(run)).build();
}
// --- Policy management ---
- @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Create or update a policy",
description =
"Stores a policy (trigger config + steps + output + metadata). A blank id is"
+ " assigned; returns the stored policy with its id.")
- public ResponseEntity savePolicy(@RequestBody String json) {
+ public Response savePolicy(String json) {
Policy policy = parsePolicy(json);
requireAuthorizedForFolderAccess(policy);
try {
policyValidator.validate(policy);
} catch (IllegalArgumentException e) {
- throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
+ throw new WebApplicationException(e.getMessage(), Response.Status.BAD_REQUEST);
}
- return ResponseEntity.ok(policyStore.save(policy));
+ return Response.ok(policyStore.save(policy)).build();
}
/**
@@ -184,36 +200,43 @@ public class PolicyController {
return;
}
if (!userService.isCurrentUserAdmin()) {
- throw new ResponseStatusException(
- HttpStatus.FORBIDDEN,
- "Folder sources and outputs may only be configured by an administrator");
+ throw new WebApplicationException(
+ "Folder sources and outputs may only be configured by an administrator",
+ Response.Status.FORBIDDEN);
}
}
- @GetMapping
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "List policies")
public List listPolicies() {
return policyStore.all();
}
- @GetMapping("/{policyId}")
+ @GET
+ @jakarta.ws.rs.Path("/{policyId}")
+ @Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Get a policy by id")
- public ResponseEntity getPolicy(@PathVariable String policyId) {
+ public Response getPolicy(@PathParam("policyId") String policyId) {
return policyStore
.get(policyId)
- .map(ResponseEntity::ok)
- .orElseGet(() -> ResponseEntity.notFound().build());
+ .map(policy -> Response.ok(policy).build())
+ .orElseGet(() -> Response.status(Response.Status.NOT_FOUND).build());
}
- @DeleteMapping("/{policyId}")
+ @DELETE
+ @jakarta.ws.rs.Path("/{policyId}")
@Operation(summary = "Delete a policy by id")
- public ResponseEntity deletePolicy(@PathVariable String policyId) {
+ public Response deletePolicy(@PathParam("policyId") String policyId) {
return policyStore.delete(policyId)
- ? ResponseEntity.noContent().build()
- : ResponseEntity.notFound().build();
+ ? Response.noContent().build()
+ : Response.status(Response.Status.NOT_FOUND).build();
}
- @PostMapping(value = "/{policyId}/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @POST
+ @jakarta.ws.rs.Path("/{policyId}/run")
+ @Consumes(MediaType.MULTIPART_FORM_DATA)
+ @Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Run a stored policy",
description =
@@ -221,25 +244,29 @@ public class PolicyController {
+ " under 'fileInput', supporting files under their asset-key fields)."
+ " Runs regardless of the policy's enabled flag, which only gates"
+ " automatic triggering. Returns a run id.")
- public ResponseEntity> runStoredPolicy(
- @PathVariable String policyId, MultipartHttpServletRequest request) throws IOException {
+ public Response runStoredPolicy(
+ @PathParam("policyId") String policyId, MultipartFormDataInput request)
+ throws IOException {
Policy policy =
policyStore
.get(policyId)
.orElseThrow(
() ->
- new ResponseStatusException(
- HttpStatus.NOT_FOUND, "No policy: " + policyId));
+ new WebApplicationException(
+ "No policy: " + policyId,
+ Response.Status.NOT_FOUND));
PolicyInputs inputs = collectInputs(request);
String runId = policyRunner.runWith(policy, inputs, PolicyProgressListener.NOOP).runId();
- return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
+ return Response.status(Response.Status.ACCEPTED)
+ .entity(new JobResponse<>(true, runId, null))
+ .build();
}
private Policy parsePolicy(String json) {
try {
return objectMapper.readValue(json, Policy.class);
} catch (JacksonException e) {
- throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid policy JSON");
+ throw new WebApplicationException("Invalid policy JSON", Response.Status.BAD_REQUEST);
}
}
@@ -248,26 +275,43 @@ public class PolicyController {
try {
definition = objectMapper.readValue(json, PipelineDefinition.class);
} catch (JacksonException e) {
- throw new ResponseStatusException(
- HttpStatus.BAD_REQUEST, "Invalid pipeline definition JSON");
+ throw new WebApplicationException(
+ "Invalid pipeline definition JSON", Response.Status.BAD_REQUEST);
}
if (definition.steps().isEmpty()) {
- throw new ResponseStatusException(
- HttpStatus.BAD_REQUEST, "Pipeline definition has no steps");
+ throw new WebApplicationException(
+ "Pipeline definition has no steps", Response.Status.BAD_REQUEST);
}
return definition;
}
+ /**
+ * Extract a single text form field from the multipart request, mirroring Spring's
+ * {@code @RequestParam} behaviour (missing field -> 400).
+ */
+ private static String formValue(MultipartFormDataInput request, String field) {
+ Collection values = request.getValues().get(field);
+ if (values != null) {
+ for (FormValue value : values) {
+ if (!value.isFileItem()) {
+ return value.getValue();
+ }
+ }
+ }
+ throw new WebApplicationException(
+ "Missing required field: " + field, Response.Status.BAD_REQUEST);
+ }
+
/**
* Split the multipart file parts into the primary document stream ("fileInput") and the named
* supporting-file store: every other file field becomes an asset keyed by its field name, which
* a step references from {@code fileParameters}.
*/
- private PolicyInputs collectInputs(MultipartHttpServletRequest request) throws IOException {
- MultiValueMap fileMap = request.getMultiFileMap();
- List primary = toResources(fileMap.get("fileInput"));
+ private PolicyInputs collectInputs(MultipartFormDataInput request) throws IOException {
+ Map> formData = request.getValues();
+ List