Compare commits

..
99 changed files with 4137 additions and 2229 deletions
@@ -308,6 +308,106 @@ public class ApplicationProperties {
* explicitly requests it via {@code AiEngineClient.postWithTimeout}.
*/
private int longRunningTimeoutSeconds = 600;
/** Timeout (seconds) for the SSE stream held open by long-running orchestrator runs. */
private int streamTimeoutSeconds = 1800;
/**
* Whether the processor pushes admin/settings-derived AI config (models, keys, RAG, limits)
* to the engine's {@code POST /api/v1/config} on startup and after a save. True lets a
* self-hosted admin drive the engine from the UI. Environment-driven deployments pin this
* false (SaaS does in application-saas.properties) so the engine stays entirely
* env-controlled and the processor never overrides its config.
*/
private boolean pushConfigToEngine = true;
/** Model + provider selection, forwarded to the engine per-request. */
private Models models = new Models();
/** Retrieval-augmented-generation (RAG) knobs, forwarded to the engine per-request. */
private Rag rag = new Rag();
/** Request size / cost guardrails. */
private Limits limits = new Limits();
/** Per-capability on/off switches so an admin can disable individual AI tools. */
private Features features = new Features();
@Data
public static class Models {
/** Provider driving the model strings: 'anthropic', 'openai', 'ollama', or 'custom'. */
private String provider = "anthropic";
/** High-quality tier model name (without provider prefix), e.g. 'claude-haiku-4-5'. */
private String smartModel = "claude-haiku-4-5";
/** Cheap/fast tier model name (without provider prefix). */
private String fastModel = "claude-haiku-4-5";
private int smartMaxTokens = 8192;
private int fastMaxTokens = 2048;
/**
* API key for the selected provider. Secret - masked in the admin API and overridable
* by the engine's own provider env var (e.g. ANTHROPIC_API_KEY) in environment-driven
* deployments. Empty means the engine falls back to its native env credentials.
*/
private String apiKey = "";
/**
* OpenAI-compatible base URL for 'ollama' / 'custom' providers (e.g.
* http://ollama:11434/v1). Ignored for anthropic/openai. SSRF-sensitive - admin only.
*/
private String baseUrl = "";
}
@Data
public static class Rag {
/**
* Embedding provider: 'voyageai', 'openai', 'ollama', or 'custom' (OpenAI-compatible).
*/
private String embeddingProvider = "voyageai";
/** Embedding model name (without provider prefix), e.g. 'voyage-4'. */
private String embeddingModel = "voyage-4";
/**
* Secret API key for the embedding provider; masked + env-overridable like
* models.apiKey.
*/
private String embeddingApiKey = "";
/**
* OpenAI-compatible base URL for 'ollama' / 'custom' embedding providers (e.g.
* http://ollama:11434/v1). Ignored for voyageai/openai. SSRF-sensitive - admin only.
*/
private String embeddingBaseUrl = "";
/** How many chunks retrieval returns per search. */
private int topK = 20;
/** Per-run cap on knowledge-search tool calls before the agent must answer. */
private int maxSearches = 5;
}
@Data
public static class Limits {
private int maxPages = 200;
private int maxCharacters = 200000;
/** Process-wide cap on concurrent model API calls (engine restart to apply). */
private int modelMaxConcurrency = 32;
}
@Data
public static class Features {
private boolean chat = true;
private boolean documentQuestions = true;
private boolean createPdf = true;
private boolean mathAuditor = true;
private boolean pdfComment = true;
private boolean classify = true;
}
}
/**
@@ -336,7 +336,19 @@ public class ConfigController {
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
// AI Engine settings
configData.put("aiEngineEnabled", applicationProperties.getAiEngine().isEnabled());
ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine();
configData.put("aiEngineEnabled", aiEngineConfig.isEnabled());
// Per-capability flags let the UI hide individual AI tools an admin has turned off.
ApplicationProperties.AiEngine.Features aiFeatures = aiEngineConfig.getFeatures();
configData.put(
"aiFeatures",
Map.ofEntries(
Map.entry("chat", aiFeatures.isChat()),
Map.entry("documentQuestions", aiFeatures.isDocumentQuestions()),
Map.entry("createPdf", aiFeatures.isCreatePdf()),
Map.entry("mathAuditor", aiFeatures.isMathAuditor()),
Map.entry("pdfComment", aiFeatures.isPdfComment()),
Map.entry("classify", aiFeatures.isClassify())));
// Timestamp TSA settings — single source of truth for presets + admin URLs
ApplicationProperties.Security.Timestamp tsConfig =
@@ -366,6 +366,35 @@ aiEngine:
enabled: false # Set to 'true' to enable the AI engine integration
url: http://localhost:5001 # URL of the Python AI engine
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
longRunningTimeoutSeconds: 600 # Timeout (seconds) for heavy operations like RAG ingestion of large documents
streamTimeoutSeconds: 1800 # SSE stream timeout (seconds) for long-running orchestrator runs
pushConfigToEngine: true # Push admin/settings AI config (models, keys, RAG, limits) to the engine on startup + save. Set false to leave the engine fully env-controlled
models:
provider: anthropic # Model provider: 'anthropic', 'openai', 'ollama', or 'custom' (OpenAI-compatible)
smartModel: claude-haiku-4-5 # High-quality tier model name (no provider prefix)
fastModel: claude-haiku-4-5 # Cheap/fast tier model name (no provider prefix)
smartMaxTokens: 8192 # Max output tokens for the smart tier
fastMaxTokens: 2048 # Max output tokens for the fast tier
apiKey: "" # API key for the selected provider (secret). Empty = engine uses its native env credentials (e.g. ANTHROPIC_API_KEY)
baseUrl: "" # OpenAI-compatible base URL for 'ollama'/'custom' providers (e.g. http://ollama:11434/v1). Ignored for anthropic/openai
rag:
embeddingProvider: voyageai # Embedding provider: 'voyageai', 'openai', 'ollama', or 'custom' (OpenAI-compatible)
embeddingModel: voyage-4 # Embedding model name (no provider prefix)
embeddingApiKey: "" # Secret API key for the embedding provider. Empty = engine uses its native env credentials (e.g. VOYAGE_API_KEY)
embeddingBaseUrl: "" # OpenAI-compatible base URL for 'ollama'/'custom' embedding providers (e.g. http://ollama:11434/v1). Ignored for voyageai/openai
topK: 20 # Number of chunks retrieval returns per search
maxSearches: 5 # Per-run cap on knowledge-search tool calls before the agent must answer
limits:
maxPages: 200 # Upper bound on PDF pages the engine will process per request
maxCharacters: 200000 # Upper bound on characters of extracted text per request
modelMaxConcurrency: 32 # Process-wide cap on concurrent model API calls (engine restart to apply)
features: # Per-capability switches; turn an individual AI tool off without disabling the whole engine
chat: true # Assistant chat
documentQuestions: true # Ask-questions-about-a-PDF
createPdf: true # Generate a PDF from a natural-language spec
mathAuditor: true # Numerical/formula contradiction auditing
pdfComment: true # AI-authored PDF comments/annotations
classify: true # Automatic document classification/labelling
policies:
# Folder automations can read from and write to the directories you allow here, so treat this as a
@@ -385,6 +414,8 @@ policies:
mcp:
enabled: false # Master switch. 'false' (default) means no /mcp endpoint, no metadata, no beans wired.
scopesEnabled: true # Enforce mcp.tools.read / mcp.tools.write scopes derived from operation category
maxRequestBytes: 10485760 # Max size (bytes) of an incoming MCP tool request payload (default 10 MB)
maxInlineResponseBytes: 10485760 # Max size (bytes) of an MCP tool response returned inline before it is rejected (default 10 MB)
allowedOperations: [] # Tool allow-list (operation ids, e.g. ['compress-pdf']). Empty = all. When set, ONLY these are exposed over MCP.
blockedOperations: [] # Tool deny-list (operation ids). Always removed from MCP even if otherwise allowed.
auth:
@@ -54,21 +54,10 @@ public class CustomAuditEventRepository implements AuditEventRepository {
return;
}
String rid = MDC.get("requestId");
String apiKeyLabel =
MDC.get(
stirling.software.proprietary.security.service
.ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY);
if (rid != null || apiKeyLabel != null) {
if (rid != null) {
clean = new java.util.HashMap<>(clean);
if (rid != null) {
clean.put("requestId", rid);
}
// Named key that made the request; surfaces as the doc source in the processor
// feed.
if (apiKeyLabel != null) {
clean.put("__apiKeyLabel", apiKeyLabel);
}
clean.put("requestId", rid);
}
String source = MDC.get("auditSource");
@@ -7,7 +7,6 @@ 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;
@@ -28,6 +27,7 @@ import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.service.TaskManager;
@@ -63,12 +63,11 @@ public class AiEngineController {
private final UserServiceInterface userService;
/**
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
* SSE emitter timeout in ms. 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}.
* executor. Derived from {@code aiEngine.streamTimeoutSeconds}.
*/
@Value("${stirling.ai.streamTimeoutMs:1800000}")
private long streamTimeoutMs;
private final long streamTimeoutMs;
public AiEngineController(
AiEngineClient aiEngineClient,
@@ -78,6 +77,7 @@ public class AiEngineController {
TaskManager taskManager,
JobOwnershipService jobOwnershipService,
AiEngineEndpointResolver endpointResolver,
ApplicationProperties applicationProperties,
@Autowired(required = false) UserServiceInterface userService) {
this.aiEngineClient = aiEngineClient;
this.aiWorkflowService = aiWorkflowService;
@@ -87,6 +87,8 @@ public class AiEngineController {
this.jobOwnershipService = jobOwnershipService;
this.endpointResolver = endpointResolver;
this.userService = userService;
this.streamTimeoutMs =
applicationProperties.getAiEngine().getStreamTimeoutSeconds() * 1000L;
}
private String currentUserId() {
@@ -36,6 +36,7 @@ import stirling.software.proprietary.classification.store.ClassificationLabelSto
import stirling.software.proprietary.model.api.ai.AiPageText;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.service.AiEngineClient;
import stirling.software.proprietary.service.AiFeatureGate;
import stirling.software.proprietary.service.PdfContentExtractor;
import tools.jackson.databind.JsonNode;
@@ -68,6 +69,7 @@ public class ClassifyLabelController {
private final PdfContentExtractor pdfContentExtractor;
private final PdfMetadataService pdfMetadataService;
private final AiEngineClient aiEngineClient;
private final AiFeatureGate aiFeatureGate;
private final ObjectMapper objectMapper;
private final UserServiceInterface userService;
@@ -86,6 +88,7 @@ public class ClassifyLabelController {
PdfContentExtractor pdfContentExtractor,
PdfMetadataService pdfMetadataService,
AiEngineClient aiEngineClient,
AiFeatureGate aiFeatureGate,
ObjectMapper objectMapper,
@Autowired(required = false) UserServiceInterface userService,
@Autowired(required = false) ClassificationLabelStore labelStore,
@@ -95,6 +98,7 @@ public class ClassifyLabelController {
this.pdfContentExtractor = pdfContentExtractor;
this.pdfMetadataService = pdfMetadataService;
this.aiEngineClient = aiEngineClient;
this.aiFeatureGate = aiFeatureGate;
this.objectMapper = objectMapper;
this.userService = userService;
this.labelStore = labelStore;
@@ -111,6 +115,7 @@ public class ClassifyLabelController {
+ " intended for direct client use.")
public ResponseEntity<Resource> classifyAndLabel(
@RequestParam("fileInput") MultipartFile fileInput) throws IOException {
aiFeatureGate.requireClassify();
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
String fileName = safeFileName(fileInput.getOriginalFilename());
@@ -1,54 +0,0 @@
package stirling.software.proprietary.controller.api;
import org.springframework.http.ResponseEntity;
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.RequestParam;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest;
import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto;
import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse;
import stirling.software.proprietary.security.service.ApiKeyManagementService;
/**
* Real backing for the portal Infrastructure → API Keys tab: list/create/revoke named, personal API
* keys. Replaces the former portal-only mock endpoint. Not gated behind an Enterprise license - API
* keys are a core auth feature available on every self-hosted instance.
*/
@ProprietaryUiDataApi
@RequiredArgsConstructor
public class PortalApiKeysController {
private final ApiKeyManagementService apiKeyManagementService;
// tier accepted for endpoint symmetry with the other infra tabs; ignored here.
@GetMapping("/infrastructure/api-keys")
@Operation(summary = "List API keys", description = "The caller's personal API keys.")
public ResponseEntity<PortalApiKeysResponse> list(
@RequestParam(value = "tier", required = false) String tier) {
return ResponseEntity.ok(apiKeyManagementService.listVisibleKeys());
}
@PostMapping("/infrastructure/api-keys")
@Operation(
summary = "Create an API key",
description = "Mints a personal key and returns its one-time secret.")
public ResponseEntity<CreatedApiKeyDto> create(@RequestBody CreateApiKeyRequest request) {
return ResponseEntity.ok(apiKeyManagementService.createKey(request));
}
@DeleteMapping("/infrastructure/api-keys/{id}")
@Operation(summary = "Revoke an API key", description = "Disables a key the caller owns.")
public ResponseEntity<Void> revoke(@PathVariable("id") Long id) {
apiKeyManagementService.revokeKey(id);
return ResponseEntity.noContent().build();
}
}
@@ -24,8 +24,8 @@ import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
/**
* API-key auth for the MCP endpoint: validates a Stirling API key and binds the request to that
* user with the MCP scopes.
* API-key auth for the MCP endpoint: validates a Stirling per-user API key and binds the request to
* that user with the MCP scopes.
*/
@Slf4j
public class McpApiKeyAuthFilter extends OncePerRequestFilter {
@@ -1,4 +0,0 @@
package stirling.software.proprietary.model.api.apikey;
/** Create-key request body from the portal: just a display name for the new personal key. */
public record CreateApiKeyRequest(String name) {}
@@ -1,7 +0,0 @@
package stirling.software.proprietary.model.api.apikey;
import lombok.Builder;
/** Returned once when a key is created: the row plus the plaintext secret, never persisted. */
@Builder
public record CreatedApiKeyDto(PortalApiKeyDto key, String secret) {}
@@ -1,21 +0,0 @@
package stirling.software.proprietary.model.api.apikey;
import lombok.Builder;
/**
* One API key as shown in the portal Infrastructure → API Keys tab. Never carries the secret; that
* is returned once from {@link CreatedApiKeyDto} at creation time.
*/
@Builder
public record PortalApiKeyDto(
String id,
String name,
String prefix,
String created,
String lastUsed,
/** "active" | "revoked". */
String status,
long usageToday,
long usageMonth,
/** Lifetime request count for the key. */
long usageTotal) {}
@@ -1,9 +0,0 @@
package stirling.software.proprietary.model.api.apikey;
import java.util.List;
import lombok.Builder;
/** Payload for the API Keys tab: the personal keys the caller owns. */
@Builder
public record PortalApiKeysResponse(List<PortalApiKeyDto> keys) {}
@@ -57,7 +57,6 @@ import stirling.software.proprietary.security.oauth2.TauriAuthorizationRequestRe
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuccessHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.CustomOAuth2UserService;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
@@ -485,14 +484,12 @@ public class SecurityConfiguration {
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter(
ApiKeyAuthenticationService apiKeyAuthenticationService) {
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(
jwtService,
userService,
userDetailsService,
jwtAuthenticationEntryPoint,
securityProperties,
apiKeyAuthenticationService);
securityProperties);
}
}
@@ -8,6 +8,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -45,6 +46,7 @@ import stirling.software.common.util.RegexPatternUtils;
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
import stirling.software.proprietary.service.AiEngineConfigSync;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@@ -58,6 +60,7 @@ public class AdminSettingsController {
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
private final ApplicationContext applicationContext;
private final AiEngineConfigSync aiEngineConfigSync;
// Track settings that have been modified but not yet applied (require restart)
private static final ConcurrentHashMap<String, Object> pendingChanges =
@@ -172,6 +175,28 @@ public class AdminSettingsController {
.body(Map.of("error", "No settings provided to update"));
}
// Work on a mutable copy so the removal below works even if the caller passed an
// immutable map, then drop masked sensitive values so a UI round-trip never overwrites
// a real secret (e.g. an API key) with the "********" placeholder from the GET
// response.
settings = new LinkedHashMap<>(settings);
settings.entrySet()
.removeIf(
e -> {
if (!"********".equals(e.getValue())) {
return false;
}
String key = e.getKey();
String leaf =
key.contains(".")
? key.substring(key.lastIndexOf('.') + 1)
: key;
return isSensitiveFieldWithPath(leaf, key);
});
if (settings.isEmpty()) {
return ResponseEntity.ok(Map.of("message", "No changed settings to update."));
}
// Validate all settings first before applying any changes
for (Map.Entry<String, Object> entry : settings.entrySet()) {
String key = entry.getKey();
@@ -206,6 +231,10 @@ public class AdminSettingsController {
pendingChanges.put(key, value != null ? value : "");
}
// If AI settings changed, push them to the engine live so model/RAG/limit changes
// apply without waiting for a processor restart.
maybePushAiEngineLive();
return ResponseEntity.ok(
Map.of(
"message",
@@ -600,6 +629,23 @@ public class AdminSettingsController {
}
}
/**
* Forward any pending {@code aiEngine.*} changes to the AI engine immediately. The running bean
* overlaid with these pending values is the current settings.yml state; {@link
* AiEngineConfigSync} no-ops unless AI is enabled and an engine-relevant key changed.
*/
private void maybePushAiEngineLive() {
Map<String, Object> aiEnginePending = new HashMap<>();
for (Map.Entry<String, Object> entry : pendingChanges.entrySet()) {
if (entry.getKey().startsWith("aiEngine.")) {
aiEnginePending.put(entry.getKey(), entry.getValue());
}
}
if (!aiEnginePending.isEmpty()) {
aiEngineConfigSync.pushLiveAfterSave(aiEnginePending);
}
}
private Object getSectionData(String sectionName) {
if (sectionName == null || sectionName.trim().isEmpty()) {
return null;
@@ -843,8 +889,13 @@ public class AdminSettingsController {
return true;
}
// Check for fields containing 'password' or 'secret'
return lowerField.contains("password") || lowerField.contains("secret");
// Substring match for secret-bearing field names. Covers provider credentials such as
// aiEngine.models.apiKey and aiEngine.rag.embeddingApiKey (which the exact-name set would
// miss) so keys are never returned to the client in cleartext.
return lowerField.contains("password")
|| lowerField.contains("secret")
|| lowerField.contains("apikey")
|| lowerField.contains("token");
}
/** Create a masked representation for sensitive fields */
@@ -11,7 +11,6 @@ import java.sql.SQLException;
import java.util.Map;
import java.util.Optional;
import org.slf4j.MDC;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
@@ -34,9 +33,8 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.UserService;
@@ -50,15 +48,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final CustomUserDetailsService userDetailsService;
private final AuthenticationEntryPoint authenticationEntryPoint;
private final ApplicationProperties.Security securityProperties;
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// Start clean so a pooled thread can't inherit a prior request's key label. This filter
// runs before UserAuthenticationFilter, so in JWT mode it owns the API-key label lifecycle.
MDC.remove(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY);
if (!jwtService.isJwtEnabled()) {
filterChain.doFilter(request, response);
return;
@@ -137,14 +131,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
if (apiKey != null && !apiKey.isBlank()) {
try {
// Resolve through the shared service so the multi-key table (then the legacy
// per-user key) is consulted and per-key usage is recorded; the key runs as its
// owner. It also yields a per-key label for the processor's document
// attribution.
Optional<ApiKeyAuthentication> resolved =
apiKeyAuthenticationService.authenticate(apiKey);
Optional<User> user = userService.getUserByApiKey(apiKey);
if (resolved.isEmpty()) {
if (user.isEmpty()) {
handleAuthenticationFailure(
request,
response,
@@ -154,13 +143,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
authentication =
new ApiKeyAuthenticationToken(
resolved.get().user(), apiKey, resolved.get().authorities());
user.get(), apiKey, user.get().getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
if (resolved.get().auditLabel() != null) {
MDC.put(
ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY,
resolved.get().auditLabel());
}
return true;
} catch (AuthenticationException e) {
handleAuthenticationFailure(
@@ -6,7 +6,6 @@ import java.io.IOException;
import java.util.List;
import java.util.Optional;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Profile;
@@ -34,8 +33,6 @@ import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@@ -44,24 +41,18 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@Profile("!saas")
public class UserAuthenticationFilter extends OncePerRequestFilter {
/** MDC key carrying the resolved key's label into audit events for the processor feed. */
public static final String API_KEY_LABEL_MDC = ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY;
private final ApplicationProperties.Security securityProp;
private final UserService userService;
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
private final SessionPersistentRegistry sessionPersistentRegistry;
private final boolean loginEnabledValue;
public UserAuthenticationFilter(
@Lazy ApplicationProperties.Security securityProp,
@Lazy UserService userService,
ApiKeyAuthenticationService apiKeyAuthenticationService,
SessionPersistentRegistry sessionPersistentRegistry,
@Qualifier("loginEnabled") boolean loginEnabledValue) {
this.securityProp = securityProp;
this.userService = userService;
this.apiKeyAuthenticationService = apiKeyAuthenticationService;
this.sessionPersistentRegistry = sessionPersistentRegistry;
this.loginEnabledValue = loginEnabledValue;
}
@@ -71,14 +62,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// Start each request clean so a pooled thread can't inherit a prior request's key label -
// but keep a label an upstream filter (JwtAuthenticationFilter) already set for a request
// it API-key-authenticated, otherwise per-key attribution is lost on the JWT path.
if (!(SecurityContextHolder.getContext().getAuthentication()
instanceof ApiKeyAuthenticationToken)) {
MDC.remove(API_KEY_LABEL_MDC);
}
if (!loginEnabledValue) {
// If login is not enabled, just pass all requests without authentication
filterChain.doFilter(request, response);
@@ -106,23 +89,18 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
String apiKey = request.getHeader("X-API-KEY");
if (apiKey != null && !apiKey.trim().isEmpty()) {
try {
// Resolves the multi-key table then the legacy key, records usage, and yields a
// per-key label for the processor's document-source attribution.
Optional<ApiKeyAuthentication> resolved =
apiKeyAuthenticationService.authenticate(apiKey);
if (resolved.isEmpty()) {
// Use API key to authenticate. This requires you to have an authentication
// provider for API keys.
Optional<User> user = userService.getUserByApiKey(apiKey);
if (user.isEmpty()) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("Invalid API Key.");
return;
}
User user = resolved.get().user();
authentication =
new ApiKeyAuthenticationToken(
user, apiKey, resolved.get().authorities());
user.get(), apiKey, user.get().getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
if (resolved.get().auditLabel() != null) {
MDC.put(API_KEY_LABEL_MDC, resolved.get().auditLabel());
}
} catch (AuthenticationException e) {
// If API key authentication fails, deny the request
response.setStatus(HttpStatus.UNAUTHORIZED.value());
@@ -11,6 +11,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@@ -57,25 +58,23 @@ public class UserBasedRateLimitingFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
return;
}
// Bucket by the resolved user (the auth filter runs first and populates the context, even
// for X-API-KEY requests), so all of a user's API keys share ONE per-user quota - minting
// extra keys can't multiply the daily limit. Fall back to the raw key / IP only when the
// request is unauthenticated.
String identifier = null;
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null
&& authentication.isAuthenticated()
&& !"anonymousUser".equals(authentication.getName())) {
identifier = authentication.getName();
}
if (identifier == null) {
String apiKey = request.getHeader("X-API-KEY");
if (apiKey != null && !apiKey.trim().isEmpty()) {
identifier = "API_KEY_" + apiKey;
} else {
identifier = request.getRemoteAddr();
// Check for API key in the request headers
String apiKey = request.getHeader("X-API-KEY");
if (apiKey != null && !apiKey.trim().isEmpty()) {
identifier = // Prefix to distinguish between API keys and usernames
"API_KEY_" + apiKey;
} else {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
identifier = userDetails.getUsername();
}
}
// If neither API key nor an authenticated user is present, use IP address
if (identifier == null) {
identifier = request.getRemoteAddr();
}
Role userRole =
getRoleFromAuthentication(SecurityContextHolder.getContext().getAuthentication());
if (request.getHeader("X-API-KEY") != null) {
@@ -1,72 +0,0 @@
package stirling.software.proprietary.security.model;
import java.io.Serializable;
import java.time.Instant;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* A named, personal API key belonging to a user. The raw secret is shown once at creation and never
* stored; only its SHA-256 hash is persisted, so a leaked database row cannot be replayed. Distinct
* from the legacy single {@code users.apiKey} column, which stays a per-user key for backward
* compatibility and is lazily represented here.
*/
@Entity
@Table(
name = "api_keys",
indexes = {
@Index(name = "idx_api_key_hash", columnList = "key_hash", unique = true),
@Index(name = "idx_api_key_owner", columnList = "owner_user_id")
})
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ApiKey implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@Column(name = "name", nullable = false, length = 100)
private String name;
/** SHA-256 hex of the raw key; the raw value is never persisted. */
@Column(name = "key_hash", nullable = false, unique = true, length = 64)
private String keyHash;
/** Non-secret leading fragment of the raw key, shown in listings (e.g. {@code sk_a1b2c3d4}). */
@Column(name = "prefix", nullable = false, length = 32)
private String prefix;
/** The user who created and owns the key; the key authenticates as this user. */
@Column(name = "owner_user_id", nullable = false)
private Long ownerUserId;
@Column(name = "enabled", nullable = false)
private boolean enabled;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "last_used_at")
private Instant lastUsedAt;
@Column(name = "revoked_at")
private Instant revokedAt;
/** Active = enabled and not revoked; only active keys authenticate. */
public boolean isActive() {
return enabled && revokedAt == null;
}
}
@@ -5,7 +5,6 @@ import java.util.Collection;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
/** Authentication produced from an {@code X-API-KEY} header; runs as the key's owner. */
public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal;
@@ -1,45 +0,0 @@
package stirling.software.proprietary.security.model;
import java.io.Serializable;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* One UTC day's request tally for an API key. Rolling "today"/"this month" usage is summed from
* these rows, keeping the table at one row per key per active day rather than one per request.
*/
@Entity
@Table(name = "api_key_daily_usage")
@IdClass(ApiKeyDailyUsageId.class)
@Getter
@Setter
@NoArgsConstructor
public class ApiKeyDailyUsage implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@Column(name = "api_key_id")
private Long apiKeyId;
@Id
@Column(name = "epoch_day")
private long epochDay;
@Column(name = "count")
private long count;
public ApiKeyDailyUsage(Long apiKeyId, long epochDay, long count) {
this.apiKeyId = apiKeyId;
this.epochDay = epochDay;
this.count = count;
}
}
@@ -1,36 +0,0 @@
package stirling.software.proprietary.security.model;
import java.io.Serializable;
import java.util.Objects;
/** Composite key for {@link ApiKeyDailyUsage}: one row per key per UTC day. */
public class ApiKeyDailyUsageId implements Serializable {
private static final long serialVersionUID = 1L;
private Long apiKeyId;
private long epochDay;
public ApiKeyDailyUsageId() {}
public ApiKeyDailyUsageId(Long apiKeyId, long epochDay) {
this.apiKeyId = apiKeyId;
this.epochDay = epochDay;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof ApiKeyDailyUsageId other)) {
return false;
}
return epochDay == other.epochDay && Objects.equals(apiKeyId, other.apiKeyId);
}
@Override
public int hashCode() {
return Objects.hash(apiKeyId, epochDay);
}
}
@@ -1,55 +0,0 @@
package stirling.software.proprietary.security.repository;
import java.util.Collection;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.proprietary.security.model.ApiKeyDailyUsage;
import stirling.software.proprietary.security.model.ApiKeyDailyUsageId;
@Repository
public interface ApiKeyDailyUsageRepository
extends JpaRepository<ApiKeyDailyUsage, ApiKeyDailyUsageId> {
/** Atomically bump today's tally; returns 0 when no row exists yet (caller then inserts). */
@Modifying
@Query(
"UPDATE ApiKeyDailyUsage u SET u.count = u.count + 1 "
+ "WHERE u.apiKeyId = :apiKeyId AND u.epochDay = :epochDay")
int incrementIfPresent(@Param("apiKeyId") Long apiKeyId, @Param("epochDay") long epochDay);
@Query(
"SELECT COALESCE(SUM(u.count), 0) FROM ApiKeyDailyUsage u "
+ "WHERE u.apiKeyId = :apiKeyId AND u.epochDay >= :fromDayInclusive")
long sumSince(
@Param("apiKeyId") Long apiKeyId, @Param("fromDayInclusive") long fromDayInclusive);
@Query(
"SELECT u.count FROM ApiKeyDailyUsage u "
+ "WHERE u.apiKeyId = :apiKeyId AND u.epochDay = :epochDay")
Long countForDay(@Param("apiKeyId") Long apiKeyId, @Param("epochDay") long epochDay);
/** Batched today-count for many keys in one query (avoids N+1 when listing keys). */
@Query(
"SELECT u.apiKeyId AS apiKeyId, u.count AS total FROM ApiKeyDailyUsage u "
+ "WHERE u.apiKeyId IN :ids AND u.epochDay = :epochDay")
List<ApiKeyUsageSum> countForDayByIds(
@Param("ids") Collection<Long> ids, @Param("epochDay") long epochDay);
/** Batched trailing-window sum for many keys in one query. */
@Query(
"SELECT u.apiKeyId AS apiKeyId, SUM(u.count) AS total FROM ApiKeyDailyUsage u "
+ "WHERE u.apiKeyId IN :ids AND u.epochDay >= :fromDayInclusive "
+ "GROUP BY u.apiKeyId")
List<ApiKeyUsageSum> sumSinceByIds(
@Param("ids") Collection<Long> ids, @Param("fromDayInclusive") long fromDayInclusive);
void deleteByApiKeyId(Long apiKeyId);
List<ApiKeyDailyUsage> findByApiKeyId(Long apiKeyId);
}
@@ -1,19 +0,0 @@
package stirling.software.proprietary.security.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import stirling.software.proprietary.security.model.ApiKey;
@Repository
public interface ApiKeyRepository extends JpaRepository<ApiKey, Long> {
Optional<ApiKey> findByKeyHash(String keyHash);
boolean existsByKeyHash(String keyHash);
List<ApiKey> findByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId);
}
@@ -1,8 +0,0 @@
package stirling.software.proprietary.security.repository;
/** Projection: a key id and a usage total, for batching per-key usage into one query. */
public interface ApiKeyUsageSum {
Long getApiKeyId();
Long getTotal();
}
@@ -1,107 +0,0 @@
package stirling.software.proprietary.security.service;
import java.time.Instant;
import java.util.Collection;
import java.util.Optional;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKey;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.ApiKeyRepository;
/**
* Resolves an incoming {@code X-API-KEY} to its owning user and records per-key usage. Depends only
* on repositories (never {@code UserService}) so {@code UserService} can delegate here without a
* bean cycle.
*
* <p>Resolution order: the multi-key {@code api_keys} table first (by hash), then the legacy
* per-user {@code users.apiKey} column. Legacy keys therefore keep working unchanged. Every key is
* personal and authenticates as its owner with the owner's authorities.
*/
@Service
@RequiredArgsConstructor
public class ApiKeyAuthenticationService {
/**
* MDC key that carries the resolved key's label into audit events so the processor's Documents
* feed can attribute a document to the specific key. Set by the auth filters (both flavors),
* read by {@code CustomAuditEventRepository}.
*/
public static final String AUDIT_LABEL_MDC_KEY = "apiKeyLabel";
private final ApiKeyRepository apiKeyRepository;
private final ApiKeyUsageRecorder usageRecorder;
private final UserRepository userRepository;
/** The user a raw key authenticates as, or empty if it matches no active key. */
public Optional<User> resolveUser(String rawKey) {
return authenticate(rawKey).map(ApiKeyAuthentication::user);
}
/**
* Resolve a raw key, recording usage as a side effect. Returns the owning user, a display label
* for the resolved key ({@code null} for the legacy per-user key), and the owner's authorities.
*/
public Optional<ApiKeyAuthentication> authenticate(String rawKey) {
if (rawKey == null || rawKey.isBlank()) {
return Optional.empty();
}
ApiKey key = apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(rawKey)).orElse(null);
if (key != null) {
if (!key.isActive()) {
return Optional.empty();
}
User owner = userRepository.findById(key.getOwnerUserId()).orElse(null);
if (owner == null || !owner.isEnabled()) {
return Optional.empty();
}
usageRecorder.record(key.getId());
return Optional.of(
new ApiKeyAuthentication(owner, auditLabel(key), owner.getAuthorities()));
}
// Legacy single per-user key: keep working, always a personal key for its user.
return userRepository
.findByApiKey(rawKey)
.filter(User::isEnabled)
.map(user -> new ApiKeyAuthentication(user, null, user.getAuthorities()));
}
/** "Production ingest (sk_a1b2c3d4)" - shown against API-sourced docs in the processor feed. */
private static String auditLabel(ApiKey key) {
return key.getName() + " (" + key.getPrefix() + ")";
}
/**
* Revoke the {@code api_keys} row that mirrors a given raw key, if any. Called when the legacy
* per-user key is rotated so the migrated shadow row can't keep authenticating the old secret.
*/
@Transactional
public void revokeMigratedKey(String rawKey) {
if (rawKey == null || rawKey.isBlank()) {
return;
}
apiKeyRepository
.findByKeyHash(ApiKeyHasher.hash(rawKey))
.filter(ApiKey::isActive)
.ifPresent(
k -> {
k.setEnabled(false);
k.setRevokedAt(Instant.now());
apiKeyRepository.save(k);
});
}
/**
* A resolved key: the user, an optional processor-feed label, and the authorities to run as.
*/
public record ApiKeyAuthentication(
User user, String auditLabel, Collection<? extends GrantedAuthority> authorities) {}
}
@@ -1,50 +0,0 @@
package stirling.software.proprietary.security.service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.HexFormat;
/** Generates opaque API-key secrets and hashes them for storage/lookup. */
public final class ApiKeyHasher {
/** Human-recognisable prefix so a leaked string is identifiable as a Stirling API key. */
public static final String KEY_PREFIX = "sk_";
/** Chars of the raw key kept for non-secret display (includes the {@code sk_} prefix). */
private static final int DISPLAY_PREFIX_LENGTH = 11;
private static final SecureRandom RANDOM = new SecureRandom();
private ApiKeyHasher() {}
/** A fresh opaque secret: {@code sk_} followed by 40 hex chars of cryptographic randomness. */
public static String generateRawKey() {
byte[] bytes = new byte[20];
RANDOM.nextBytes(bytes);
return KEY_PREFIX + HexFormat.of().formatHex(bytes);
}
/** SHA-256 hex of a raw key; the value stored and looked up, never the raw key. */
public static String hash(String rawKey) {
try {
byte[] digest =
MessageDigest.getInstance("SHA-256")
.digest(rawKey.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
/** Leading, non-secret fragment shown in listings (e.g. {@code sk_a1b2c3d4}). */
public static String displayPrefix(String rawKey) {
if (rawKey == null) {
return "";
}
return rawKey.length() <= DISPLAY_PREFIX_LENGTH
? rawKey
: rawKey.substring(0, DISPLAY_PREFIX_LENGTH);
}
}
@@ -1,31 +0,0 @@
package stirling.software.proprietary.security.service;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.model.ApiKey;
import stirling.software.proprietary.security.repository.ApiKeyRepository;
/**
* Inserts the shadow {@code api_keys} row that mirrors a user's legacy {@code users.apiKey} in its
* OWN ({@code REQUIRES_NEW}) transaction. Kept a separate bean so the write is isolated from the
* caller's listing transaction: when two concurrent first-loads race to insert the same hash, the
* loser's unique-key clash rolls back only this insert instead of poisoning the caller's
* transaction (on Postgres a failed statement aborts the whole transaction). The {@code
* DataIntegrityViolationException} is left to propagate so the caller can treat it as "already
* migrated".
*/
@Component
@RequiredArgsConstructor
class ApiKeyLegacyMigrator {
private final ApiKeyRepository apiKeyRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void insertMigratedKey(ApiKey key) {
apiKeyRepository.saveAndFlush(key);
}
}
@@ -1,235 +0,0 @@
package stirling.software.proprietary.security.service;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest;
import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto;
import stirling.software.proprietary.model.api.apikey.PortalApiKeyDto;
import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKey;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository;
import stirling.software.proprietary.security.repository.ApiKeyRepository;
/**
* Portal-facing CRUD for named, personal API keys: lists, creates, and revokes the caller's own
* keys. Every key belongs to exactly one user and authenticates as that user; there is no sharing.
*
* <p>Every pre-existing single {@code users.apiKey} is lazily represented as a key owned by that
* user, so historic keys list uniformly.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ApiKeyManagementService {
private static final DateTimeFormatter CREATED_FORMAT =
DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneOffset.UTC);
private static final DateTimeFormatter LAST_USED_FORMAT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneOffset.UTC);
private static final int MONTH_WINDOW_DAYS = 30;
/** Bounds a key name so it can't bloat storage or the audit/processor feed. */
private static final int MAX_NAME_LENGTH = 100;
/** Caps active keys per user so key creation can't be used to multiply rate-limit budget. */
private static final int MAX_ACTIVE_KEYS_PER_USER = 50;
private final ApiKeyRepository apiKeyRepository;
private final ApiKeyDailyUsageRepository usageRepository;
private final UserRepository userRepository;
private final UserService userService;
private final ApiKeyLegacyMigrator legacyMigrator;
/** All keys the caller owns. */
@Transactional
public PortalApiKeysResponse listVisibleKeys() {
User caller = requireCaller();
migrateLegacyKey(caller);
List<ApiKey> visible =
apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(caller.getId());
// Batch usage for all keys into three queries rather than two-per-key (avoids N+1).
long today = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay();
List<Long> ids = visible.stream().map(ApiKey::getId).toList();
Map<Long, Long> todayById = new HashMap<>();
Map<Long, Long> monthById = new HashMap<>();
Map<Long, Long> totalById = new HashMap<>();
if (!ids.isEmpty()) {
usageRepository
.countForDayByIds(ids, today)
.forEach(r -> todayById.put(r.getApiKeyId(), r.getTotal()));
usageRepository
.sumSinceByIds(ids, today - (MONTH_WINDOW_DAYS - 1))
.forEach(r -> monthById.put(r.getApiKeyId(), r.getTotal()));
usageRepository
.sumSinceByIds(ids, Long.MIN_VALUE)
.forEach(r -> totalById.put(r.getApiKeyId(), r.getTotal()));
}
List<PortalApiKeyDto> keys =
visible.stream()
.map(
k ->
toDto(
k,
zeroIfNull(todayById.get(k.getId())),
zeroIfNull(monthById.get(k.getId())),
zeroIfNull(totalById.get(k.getId()))))
.toList();
return PortalApiKeysResponse.builder().keys(keys).build();
}
private static long zeroIfNull(Long value) {
return value == null ? 0L : value;
}
/** Create a personal key and return its one-time secret. */
@Transactional
public CreatedApiKeyDto createKey(CreateApiKeyRequest request) {
User caller = requireCaller();
String name = request == null ? null : request.name();
if (name == null || name.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Key name is required");
}
if (name.trim().length() > MAX_NAME_LENGTH) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Key name must be " + MAX_NAME_LENGTH + " characters or fewer");
}
long activeOwned =
apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(caller.getId()).stream()
.filter(ApiKey::isActive)
.count();
if (activeOwned >= MAX_ACTIVE_KEYS_PER_USER) {
throw new ResponseStatusException(
HttpStatus.TOO_MANY_REQUESTS,
"You have reached the maximum of "
+ MAX_ACTIVE_KEYS_PER_USER
+ " active API keys; revoke one before creating another");
}
String rawKey = ApiKeyHasher.generateRawKey();
ApiKey saved =
apiKeyRepository.save(
ApiKey.builder()
.name(name.trim())
.keyHash(ApiKeyHasher.hash(rawKey))
.prefix(ApiKeyHasher.displayPrefix(rawKey))
.ownerUserId(caller.getId())
.enabled(true)
.createdAt(Instant.now())
.build());
return CreatedApiKeyDto.builder().key(toDto(saved, 0L, 0L, 0L)).secret(rawKey).build();
}
/** Soft-revoke a key the caller owns; also clears the legacy column if it is that key. */
@Transactional
public void revokeKey(Long id) {
User caller = requireCaller();
ApiKey key =
apiKeyRepository
.findById(id)
.orElseThrow(
() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "No key"));
if (!key.getOwnerUserId().equals(caller.getId())) {
// Not-found rather than forbidden so a caller can't probe other users' key ids.
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No key");
}
key.setEnabled(false);
key.setRevokedAt(Instant.now());
apiKeyRepository.save(key);
clearLegacyColumnIfMatches(key);
}
/** Represent a user's pre-existing single key as a row so it lists uniformly. */
private void migrateLegacyKey(User user) {
String legacy = user.getApiKey();
if (legacy == null || legacy.isBlank()) {
return;
}
String hash = ApiKeyHasher.hash(legacy);
if (apiKeyRepository.existsByKeyHash(hash)) {
return;
}
try {
// Insert in its own transaction so a concurrent-insert clash can't poison this
// listing transaction (see ApiKeyLegacyMigrator).
legacyMigrator.insertMigratedKey(
ApiKey.builder()
.name("Default key")
.keyHash(hash)
.prefix(ApiKeyHasher.displayPrefix(legacy))
.ownerUserId(user.getId())
.enabled(true)
.createdAt(Instant.now())
.build());
} catch (DataIntegrityViolationException alreadyMigrated) {
// A concurrent first-load won the race and inserted the same hash; that's fine.
log.debug("Legacy key already migrated concurrently for user {}", user.getId());
}
}
/**
* If a revoked key is the owner's legacy {@code users.apiKey}, null it so it stops resolving.
*/
private void clearLegacyColumnIfMatches(ApiKey key) {
userRepository
.findById(key.getOwnerUserId())
.ifPresent(
owner -> {
String legacy = owner.getApiKey();
if (legacy != null
&& ApiKeyHasher.hash(legacy).equals(key.getKeyHash())) {
owner.setApiKey(null);
userRepository.save(owner);
}
});
}
private PortalApiKeyDto toDto(ApiKey key, long usageToday, long usageMonth, long usageTotal) {
return PortalApiKeyDto.builder()
.id(String.valueOf(key.getId()))
.name(key.getName())
.prefix(key.getPrefix())
.created(
key.getCreatedAt() == null ? "" : CREATED_FORMAT.format(key.getCreatedAt()))
.lastUsed(
key.getLastUsedAt() == null
? "Never"
: LAST_USED_FORMAT.format(key.getLastUsedAt()))
.status(key.isActive() ? "active" : "revoked")
.usageToday(usageToday)
.usageMonth(usageMonth)
.usageTotal(usageTotal)
.build();
}
private User requireCaller() {
String username = userService.getCurrentUsername();
if (username == null || username.isBlank() || "anonymousUser".equalsIgnoreCase(username)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
}
return userService
.findByUsernameIgnoreCase(username)
.orElseThrow(
() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unknown user"));
}
}
@@ -1,60 +0,0 @@
package stirling.software.proprietary.security.service;
import java.time.Instant;
import java.time.ZoneOffset;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Records per-key usage off the request thread. Kept a separate bean so the {@code @Async} proxy is
* honoured (a self-invocation from the resolver would run inline). Best-effort: never fails a
* request. The actual writes go through {@link ApiKeyUsageWriter} so each step commits in its own
* transaction and a first-write race can't drop a count.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ApiKeyUsageRecorder {
private final ApiKeyUsageWriter writer;
/** Bump today's tally for the key and stamp last-used. */
@Async("auditExecutor")
public void record(Long apiKeyId) {
if (apiKeyId == null) {
return;
}
try {
long epochDay = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay();
// First writer of the day inserts the row; everyone else (and the loser of an insert
// race) increments. Separate transactions mean a unique-key clash never rolls back an
// already-counted request.
if (writer.increment(apiKeyId, epochDay) == 0
&& !firstUseInserted(apiKeyId, epochDay)) {
writer.increment(apiKeyId, epochDay);
}
writer.stampLastUsed(apiKeyId);
} catch (Exception e) {
log.debug("Failed to record API key usage for id={}", apiKeyId, e);
}
}
/**
* Whether we inserted the day's first row. A lost insert race can surface either as a {@code
* false} return or - when the failed flush marked the REQUIRES_NEW transaction rollback-only,
* so its commit throws - as an exception; both mean "someone else inserted", so we treat any
* failure as not-inserted and let the caller fall back to an increment rather than dropping the
* count.
*/
private boolean firstUseInserted(Long apiKeyId, long epochDay) {
try {
return writer.tryInsertFirstUse(apiKeyId, epochDay);
} catch (RuntimeException raced) {
return false;
}
}
}
@@ -1,59 +0,0 @@
package stirling.software.proprietary.security.service;
import java.time.Instant;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.model.ApiKeyDailyUsage;
import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository;
import stirling.software.proprietary.security.repository.ApiKeyRepository;
/**
* Per-step transactional writes for {@link ApiKeyUsageRecorder}. Each method runs in its own
* ({@code REQUIRES_NEW}) transaction so a unique-key clash when two requests race to insert the
* day's first row rolls back only that failed insert - never an already-counted request or the
* last-used stamp.
*/
@Component
@RequiredArgsConstructor
class ApiKeyUsageWriter {
private final ApiKeyRepository apiKeyRepository;
private final ApiKeyDailyUsageRepository usageRepository;
/** Bump today's tally if the row already exists; returns rows updated (0 if none yet). */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public int increment(Long apiKeyId, long epochDay) {
return usageRepository.incrementIfPresent(apiKeyId, epochDay);
}
/**
* Insert today's row with a count of 1. Flushes so a concurrent first-write's unique-key clash
* surfaces here (returning false) instead of at commit; the caller then increments instead.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean tryInsertFirstUse(Long apiKeyId, long epochDay) {
try {
usageRepository.saveAndFlush(new ApiKeyDailyUsage(apiKeyId, epochDay, 1));
return true;
} catch (DataIntegrityViolationException raced) {
return false;
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void stampLastUsed(Long apiKeyId) {
apiKeyRepository
.findById(apiKeyId)
.ifPresent(
key -> {
key.setLastUsedAt(Instant.now());
apiKeyRepository.save(key);
});
}
}
@@ -95,7 +95,6 @@ public class UserService implements UserServiceInterface {
private final ResourceGrantRepository resourceGrantRepository;
private final IntegrationConfigRepository integrationConfigRepository;
private final TeamMembershipService teamMembershipService;
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
@Transactional
public void processSSOPostLogin(
@@ -148,16 +147,15 @@ public class UserService implements UserServiceInterface {
}
public Authentication getAuthentication(String apiKey) {
// Resolve through the shared service (multi-key table, then the legacy per-user column).
// The key runs as its owner with the owner's authorities.
var resolved =
apiKeyAuthenticationService
.authenticate(apiKey)
.orElseThrow(() -> new UsernameNotFoundException("API key is not valid"));
return new UsernamePasswordAuthenticationToken(
resolved.user(), // principal
null, // credentials (we don't expose the password or API key here)
resolved.authorities()); // the owner's authorities
Optional<User> user = getUserByApiKey(apiKey);
if (user.isEmpty()) {
throw new UsernameNotFoundException("API key is not valid");
}
// Convert the user into an Authentication object
return new UsernamePasswordAuthenticationToken( // principal (typically the user)
user, // credentials (we don't expose the password or API key here)
null, // user's authorities (roles/permissions)
getAuthorities(user.get()));
}
private Collection<? extends GrantedAuthority> getAuthorities(User user) {
@@ -175,9 +173,6 @@ public class UserService implements UserServiceInterface {
public User addApiKeyToUser(String username) {
Optional<User> userOpt = findByUsernameIgnoreCase(username);
// Rotating/regenerating the legacy key must also revoke its migrated api_keys shadow row,
// otherwise the old secret keeps authenticating (it resolves from api_keys first).
userOpt.map(User::getApiKey).ifPresent(apiKeyAuthenticationService::revokeMigratedKey);
User user = saveUser(userOpt, generateApiKey());
try {
databaseService.exportDatabase();
@@ -225,8 +220,7 @@ public class UserService implements UserServiceInterface {
}
public Optional<User> getUserByApiKey(String apiKey) {
// Resolves the multi-key api_keys table first, then the legacy per-user column.
return apiKeyAuthenticationService.resolveUser(apiKey);
return userRepository.findByApiKey(apiKey);
}
public Optional<User> loadUserByApiKey(String apiKey) {
@@ -0,0 +1,181 @@
package stirling.software.proprietary.service;
import java.util.Map;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.AiEngine;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Pushes the admin-configured AI settings (model provider/name, per-provider API key, RAG choices,
* limits) to the Python engine so a self-hosted deployment can drive the engine's model and API key
* from the Stirling settings UI. Pushed on processor startup and again live whenever AI settings
* are saved (so model/RAG/limit changes apply without a restart). The engine applies it live
* (rebuilds its models) and caches it, so it self-restores on its own reboot. Empty
* key/baseUrl/model fields mean "keep the engine's own environment credential".
*
* <p>Gated by {@code aiEngine.pushConfigToEngine} (default true). Environment-driven deployments
* pin it false (SaaS does so in application-saas.properties) so the engine stays entirely
* env-controlled and the processor never pushes settings-derived config to it. Best-effort and
* non-blocking: a slow or unreachable engine never delays or fails Stirling startup.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiEngineConfigSync {
private static final int MAX_ATTEMPTS = 5;
private static final long RETRY_DELAY_MS = 3000L;
private final ApplicationProperties applicationProperties;
private final AiEngineClient aiEngineClient;
private final ObjectMapper objectMapper;
@EventListener(ApplicationReadyEvent.class)
public void pushConfigOnStartup() {
AiEngine cfg = applicationProperties.getAiEngine();
if (!cfg.isEnabled()) {
return;
}
if (!cfg.isPushConfigToEngine()) {
log.debug(
"Skipping AI engine config push: aiEngine.pushConfigToEngine is disabled"
+ " (the engine is configured from its own environment)");
return;
}
// Engine may still be booting; push on a virtual thread with a few retries so we never
// block or crash Stirling startup when the engine is slow or briefly unreachable.
Thread.ofVirtual().name("ai-engine-config-sync").start(() -> pushWithRetries(cfg));
}
/**
* Push AI settings to the engine immediately after an admin save so model/RAG/limit changes
* reach the engine without waiting for a processor restart. {@code pendingAiEngine} are the
* pending {@code aiEngine.*} dot-notation changes; the running bean overlaid with these is the
* current settings.yml state. No-op unless AI is enabled and an engine-relevant key changed.
*/
public void pushLiveAfterSave(Map<String, Object> pendingAiEngine) {
// Gate on the RUNNING bean: AiEngineClient refuses calls while the bean is disabled, so
// pushing on a pending-but-not-restarted enable would always fail. The post-restart
// startup push covers first-time enablement.
AiEngine cfg = applicationProperties.getAiEngine();
if (pendingAiEngine == null
|| pendingAiEngine.isEmpty()
|| !cfg.isPushConfigToEngine()
|| !cfg.isEnabled()) {
return;
}
boolean engineRelevant =
pendingAiEngine.keySet().stream().anyMatch(AiEngineConfigSync::isEngineRelevantKey);
if (!engineRelevant) {
return;
}
ObjectNode node = buildConfigNode(cfg);
pendingAiEngine.forEach((k, v) -> overlayIfEngineRelevant(node, k, v));
String body = node.toString();
Thread.ofVirtual().name("ai-engine-config-live-push").start(() -> pushOnce(body));
}
private void pushOnce(String body) {
try {
aiEngineClient.post("/api/v1/config", body, null);
log.info("Pushed AI engine configuration after settings change");
} catch (Exception e) {
log.warn(
"Live AI engine config push failed: {} (will re-sync on next restart)",
e.getMessage());
}
}
// Only models/rag/limits are forwarded to the engine; enabled/url/timeouts/features are
// processor-side and don't need a live engine push.
private static boolean isEngineRelevantKey(String key) {
return key.startsWith("aiEngine.models.")
|| key.startsWith("aiEngine.rag.")
|| key.startsWith("aiEngine.limits.");
}
private void overlayIfEngineRelevant(ObjectNode node, String key, Object value) {
if (!isEngineRelevantKey(key)) {
return;
}
String[] parts = key.substring("aiEngine.".length()).split("\\.");
ObjectNode parent = node;
for (int i = 0; i < parts.length - 1; i++) {
JsonNode child = parent.get(parts[i]);
parent = (child instanceof ObjectNode on) ? on : parent.putObject(parts[i]);
}
parent.set(parts[parts.length - 1], objectMapper.valueToTree(value));
}
private void pushWithRetries(AiEngine cfg) {
String body = buildConfigNode(cfg).toString();
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
aiEngineClient.post("/api/v1/config", body, null);
log.info("Pushed AI engine configuration on startup (attempt {})", attempt);
return;
} catch (Exception e) {
log.warn(
"AI engine config push failed (attempt {}/{}): {}",
attempt,
MAX_ATTEMPTS,
e.getMessage());
if (attempt < MAX_ATTEMPTS) {
try {
Thread.sleep(RETRY_DELAY_MS);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
}
}
}
}
log.warn(
"Giving up pushing AI engine configuration after {} attempts; the engine will use"
+ " its own environment configuration until the next restart.",
MAX_ATTEMPTS);
}
private ObjectNode buildConfigNode(AiEngine cfg) {
AiEngine.Models m = cfg.getModels();
AiEngine.Rag r = cfg.getRag();
AiEngine.Limits l = cfg.getLimits();
ObjectNode root = objectMapper.createObjectNode();
ObjectNode models = root.putObject("models");
models.put("provider", m.getProvider());
models.put("smartModel", m.getSmartModel());
models.put("fastModel", m.getFastModel());
models.put("smartMaxTokens", m.getSmartMaxTokens());
models.put("fastMaxTokens", m.getFastMaxTokens());
models.put("apiKey", m.getApiKey());
models.put("baseUrl", m.getBaseUrl());
ObjectNode rag = root.putObject("rag");
rag.put("embeddingProvider", r.getEmbeddingProvider());
rag.put("embeddingModel", r.getEmbeddingModel());
rag.put("embeddingApiKey", r.getEmbeddingApiKey());
rag.put("embeddingBaseUrl", r.getEmbeddingBaseUrl());
rag.put("topK", r.getTopK());
rag.put("maxSearches", r.getMaxSearches());
ObjectNode limits = root.putObject("limits");
limits.put("maxPages", l.getMaxPages());
limits.put("maxCharacters", l.getMaxCharacters());
limits.put("modelMaxConcurrency", l.getModelMaxConcurrency());
return root;
}
}
@@ -0,0 +1,58 @@
package stirling.software.proprietary.service;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.AiEngine.Features;
/**
* Central gate for the per-capability AI feature switches ({@code aiEngine.features.*}). Each AI
* capability entry point calls the matching {@code require*} method so an admin who turns a feature
* off in the settings UI gets a clean 503 instead of the request silently reaching the engine. All
* checks also fail closed when the AI engine itself is disabled.
*/
@Component
@RequiredArgsConstructor
public class AiFeatureGate {
private final ApplicationProperties applicationProperties;
private Features features() {
return applicationProperties.getAiEngine().getFeatures();
}
private void require(boolean featureEnabled, String feature) {
if (!applicationProperties.getAiEngine().isEnabled() || !featureEnabled) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "AI feature '" + feature + "' is disabled");
}
}
public void requireChat() {
require(features().isChat(), "chat");
}
public void requireDocumentQuestions() {
require(features().isDocumentQuestions(), "documentQuestions");
}
public void requireCreatePdf() {
require(features().isCreatePdf(), "createPdf");
}
public void requireMathAuditor() {
require(features().isMathAuditor(), "mathAuditor");
}
public void requirePdfComment() {
require(features().isPdfComment(), "pdfComment");
}
public void requireClassify() {
require(features().isClassify(), "classify");
}
}
@@ -65,13 +65,12 @@ public class PortalDocumentsService {
// "API". The automation marker distinguishes a policy-run step from real API traffic.
boolean automation = isAutomation(data);
String policyName = asString(data.get("policyName"));
String origin = asString(data.get("__origin"));
String source =
automation
? (policyName != null && !policyName.isBlank()
? "Policy: " + policyName
: "Policy automation")
: sourceLabel(origin, asString(data.get("__apiKeyLabel")));
: sourceLabel(asString(data.get("__origin")));
String product = automation ? "Automation" : productLabel(source);
String action = prettyTool(path);
boolean failed = isFailure(data);
@@ -174,12 +173,9 @@ public class PortalDocumentsService {
return code instanceof Number n && n.intValue() >= 400;
}
private static String sourceLabel(String origin, String apiKeyLabel) {
private static String sourceLabel(String origin) {
if ("API".equals(origin)) {
// Attribute to the specific named key when known, else the generic API channel.
return apiKeyLabel != null && !apiKeyLabel.isBlank()
? "API key · " + apiKeyLabel
: "API integration";
return "API integration";
}
if ("SYSTEM".equals(origin)) {
return "System";
@@ -188,8 +184,7 @@ public class PortalDocumentsService {
}
private static String productLabel(String source) {
// Covers both the generic "API integration" and per-key "API key · <label>" sources.
return source != null && source.startsWith("API") ? "API" : "Editor";
return "API integration".equals(source) ? "API" : "Editor";
}
private static boolean isAutomation(Map<String, Object> data) {
@@ -32,6 +32,7 @@ import stirling.software.proprietary.classification.model.ClassificationLabels;
import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.service.AiEngineClient;
import stirling.software.proprietary.service.AiFeatureGate;
import stirling.software.proprietary.service.PdfContentExtractor;
import tools.jackson.databind.JsonNode;
@@ -49,6 +50,7 @@ class ClassifyLabelControllerTest {
@Mock private PdfContentExtractor pdfContentExtractor;
@Mock private PdfMetadataService pdfMetadataService;
@Mock private AiEngineClient aiEngineClient;
@Mock private AiFeatureGate aiFeatureGate;
@Mock private PolicyManagementAuthority policyManagementAuthority;
private final ObjectMapper objectMapper = JsonMapper.builder().build();
@@ -65,6 +67,7 @@ class ClassifyLabelControllerTest {
pdfContentExtractor,
pdfMetadataService,
aiEngineClient,
aiFeatureGate,
objectMapper,
null,
labelStore,
@@ -29,7 +29,6 @@ import stirling.software.proprietary.security.database.repository.PersistentLogi
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
@@ -161,9 +160,7 @@ class SecurityConfigurationTest {
@Test
@DisplayName("jwtAuthenticationFilter is created")
void jwtAuthenticationFilter() {
JwtAuthenticationFilter filter =
newConfig(true)
.jwtAuthenticationFilter(mock(ApiKeyAuthenticationService.class));
JwtAuthenticationFilter filter = newConfig(true).jwtAuthenticationFilter();
assertThat(filter).isNotNull();
}
@@ -1,10 +1,13 @@
package stirling.software.proprietary.security.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -26,6 +29,7 @@ import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
import stirling.software.proprietary.service.AiEngineConfigSync;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@@ -37,6 +41,7 @@ class AdminSettingsControllerTest {
private ApplicationProperties applicationProperties;
private ObjectMapper objectMapper;
private ApplicationContext applicationContext;
private AiEngineConfigSync aiEngineConfigSync;
private AdminSettingsController controller;
@@ -45,9 +50,13 @@ class AdminSettingsControllerTest {
applicationProperties = new ApplicationProperties();
objectMapper = JsonMapper.builder().build();
applicationContext = org.mockito.Mockito.mock(ApplicationContext.class);
aiEngineConfigSync = org.mockito.Mockito.mock(AiEngineConfigSync.class);
controller =
new AdminSettingsController(
applicationProperties, objectMapper, applicationContext);
applicationProperties,
objectMapper,
applicationContext,
aiEngineConfigSync);
clearPendingChanges();
}
@@ -277,6 +286,51 @@ class AdminSettingsControllerTest {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
}
@Test
@DisplayName("drops a masked ******** secret so a UI round-trip can't overwrite a real key")
void dropsMaskedSecretValue() {
UpdateSettingsRequest request = new UpdateSettingsRequest();
Map<String, Object> settings = new HashMap<>();
settings.put("aiEngine.models.apiKey", "********");
settings.put("ui.appName", "My App");
request.setSettings(settings);
try (MockedStatic<GeneralUtils> mocked = mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// The masked secret is stripped; only the real change is persisted.
mocked.verify(
() ->
GeneralUtils.updateSettingsTransactional(
argThat(
(Map<String, Object> m) ->
!m.containsKey("aiEngine.models.apiKey")
&& m.containsKey("ui.appName"))));
}
}
@Test
@DisplayName("forwards only aiEngine.* pending keys to the engine live-push")
void forwardsOnlyAiEngineKeysToLivePush() {
UpdateSettingsRequest request = new UpdateSettingsRequest();
Map<String, Object> settings = new HashMap<>();
settings.put("aiEngine.models.provider", "ollama");
settings.put("ui.appName", "My App");
request.setSettings(settings);
try (MockedStatic<GeneralUtils> mocked = mockStatic(GeneralUtils.class)) {
controller.updateSettings(request);
verify(aiEngineConfigSync)
.pushLiveAfterSave(
argThat(
(Map<String, Object> m) ->
m.containsKey("aiEngine.models.provider")
&& !m.containsKey("ui.appName")));
}
}
}
@Nested
@@ -29,8 +29,6 @@ import org.springframework.security.core.session.SessionInformation;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@@ -39,7 +37,6 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
class UserAuthenticationFilterTest {
@Mock private UserService userService;
@Mock private ApiKeyAuthenticationService apiKeyAuthenticationService;
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
private ApplicationProperties.Security securityProp;
@@ -63,11 +60,7 @@ class UserAuthenticationFilterTest {
private UserAuthenticationFilter filter(boolean loginEnabled) {
return new UserAuthenticationFilter(
securityProp,
userService,
apiKeyAuthenticationService,
sessionPersistentRegistry,
loginEnabled);
securityProp, userService, sessionPersistentRegistry, loginEnabled);
}
private static User enabledUser(String username) {
@@ -106,11 +99,7 @@ class UserAuthenticationFilterTest {
User user = enabledUser("api-user");
user.addAuthority(
new stirling.software.proprietary.security.model.Authority("ROLE_USER", user));
when(apiKeyAuthenticationService.authenticate("good-key"))
.thenReturn(
Optional.of(
new ApiKeyAuthentication(
user, "Prod (sk_demo0000)", user.getAuthorities())));
when(userService.getUserByApiKey("good-key")).thenReturn(Optional.of(user));
when(userService.usernameExistsIgnoreCase("api-user")).thenReturn(true);
when(userService.isUserDisabled("api-user")).thenReturn(false);
when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean()))
@@ -128,7 +117,7 @@ class UserAuthenticationFilterTest {
void invalidApiKeyRejected() throws Exception {
request.setRequestURI("/api/v1/some/protected");
request.addHeader("X-API-KEY", "bad-key");
when(apiKeyAuthenticationService.authenticate("bad-key")).thenReturn(Optional.empty());
when(userService.getUserByApiKey("bad-key")).thenReturn(Optional.empty());
filter(true).doFilter(request, response, filterChain);
@@ -1,95 +0,0 @@
package stirling.software.proprietary.security.filter;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
@ExtendWith(MockitoExtension.class)
@DisplayName("UserBasedRateLimitingFilter")
class UserBasedRateLimitingFilterTest {
@AfterEach
void clear() {
SecurityContextHolder.clearContext();
}
private void authenticateAs(String username) {
User u = new User();
u.setUsername(username);
u.setEnabled(true);
SecurityContextHolder.getContext()
.setAuthentication(
new ApiKeyAuthenticationToken(
u,
"irrelevant",
List.of(new SimpleGrantedAuthority(Role.USER.getRoleId()))));
}
private long remainingAfterApiPost(UserBasedRateLimitingFilter filter, String apiKey)
throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/x");
req.addHeader("X-API-KEY", apiKey);
MockHttpServletResponse res = new MockHttpServletResponse();
filter.doFilter(req, res, new MockFilterChain());
return Long.parseLong(res.getHeader("X-Rate-Limit-Remaining"));
}
@Test
@DisplayName("all of a user's keys share ONE bucket - minting keys can't multiply the quota")
void keysShareOnePerUserBucket() throws Exception {
UserBasedRateLimitingFilter filter = new UserBasedRateLimitingFilter(true);
authenticateAs("alice");
long afterKeyA = remainingAfterApiPost(filter, "key-A");
long afterKeyB = remainingAfterApiPost(filter, "key-B"); // different key, same user
// The second (different) key drew from the SAME per-user bucket, so remaining fell by one.
// If it were keyed per-API-key, both would report the same remaining.
assertThat(afterKeyB).isEqualTo(afterKeyA - 1);
}
@Test
@DisplayName("non-POST requests are not rate limited")
void nonPostPassesThrough() throws Exception {
UserBasedRateLimitingFilter filter = new UserBasedRateLimitingFilter(true);
authenticateAs("alice");
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/general/x");
req.addHeader("X-API-KEY", "key-A");
MockHttpServletResponse res = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(req, res, chain);
assertThat(res.getHeader("X-Rate-Limit-Remaining")).isNull();
assertThat(chain.getRequest()).isNotNull(); // passed down the chain
}
@Test
@DisplayName("rate limiting disabled: passes through untouched")
void disabledPassesThrough() throws Exception {
UserBasedRateLimitingFilter filter = new UserBasedRateLimitingFilter(false);
authenticateAs("alice");
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/x");
req.addHeader("X-API-KEY", "key-A");
MockHttpServletResponse res = new MockHttpServletResponse();
filter.doFilter(req, res, new MockFilterChain());
assertThat(res.getHeader("X-Rate-Limit-Remaining")).isNull();
}
}
@@ -1,148 +0,0 @@
package stirling.software.proprietary.security.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.GrantedAuthority;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKey;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.ApiKeyRepository;
@ExtendWith(MockitoExtension.class)
@DisplayName("ApiKeyAuthenticationService")
class ApiKeyAuthenticationServiceTest {
@Mock private ApiKeyRepository apiKeyRepository;
@Mock private ApiKeyUsageRecorder usageRecorder;
@Mock private UserRepository userRepository;
@InjectMocks private ApiKeyAuthenticationService service;
private User user(long id, boolean enabled) {
User u = new User();
u.setId(id);
u.setUsername("user" + id);
u.setEnabled(enabled);
return u;
}
private ApiKey key(long id, long ownerId, boolean enabled, Instant revoked) {
return ApiKey.builder()
.id(id)
.name("Production ingest")
.keyHash(ApiKeyHasher.hash("raw-" + id))
.prefix("sk_demo0000")
.ownerUserId(ownerId)
.enabled(enabled)
.revokedAt(revoked)
.createdAt(Instant.now())
.build();
}
@Test
@DisplayName("resolves an active multi-key to its owner and records usage")
void resolvesActiveKey() {
String raw = "raw-1";
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
.thenReturn(Optional.of(key(1, 7, true, null)));
when(userRepository.findById(7L)).thenReturn(Optional.of(user(7, true)));
var result = service.authenticate(raw);
assertThat(result).isPresent();
assertThat(result.get().user().getId()).isEqualTo(7L);
assertThat(result.get().auditLabel()).isEqualTo("Production ingest (sk_demo0000)");
verify(usageRecorder).record(1L);
}
@Test
@DisplayName("rejects a revoked key without recording usage")
void rejectsRevokedKey() {
String raw = "raw-2";
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
.thenReturn(Optional.of(key(2, 7, true, Instant.now())));
assertThat(service.authenticate(raw)).isEmpty();
verifyNoInteractions(usageRecorder);
}
@Test
@DisplayName("rejects a key whose owner is disabled")
void rejectsDisabledOwner() {
String raw = "raw-3";
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
.thenReturn(Optional.of(key(3, 8, true, null)));
when(userRepository.findById(8L)).thenReturn(Optional.of(user(8, false)));
assertThat(service.authenticate(raw)).isEmpty();
}
@Test
@DisplayName("falls back to the legacy per-user column, with no per-key label")
void legacyFallback() {
String raw = "legacy-key";
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw))).thenReturn(Optional.empty());
when(userRepository.findByApiKey(raw)).thenReturn(Optional.of(user(9, true)));
var result = service.authenticate(raw);
assertThat(result).isPresent();
assertThat(result.get().user().getId()).isEqualTo(9L);
assertThat(result.get().auditLabel()).isNull();
verifyNoInteractions(usageRecorder);
}
@Test
@DisplayName("blank keys resolve to nothing")
void blankKey() {
assertThat(service.authenticate(" ")).isEmpty();
assertThat(service.resolveUser(null)).isEmpty();
}
@Test
@DisplayName("a key authenticates with its owner's authorities (owner acts as self)")
void keyKeepsOwnerAuthorities() {
String raw = "raw-6";
User owner = user(8, true);
owner.addAuthority(new Authority(Role.ADMIN.getRoleId(), owner));
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
.thenReturn(Optional.of(key(6, 8, true, null)));
when(userRepository.findById(8L)).thenReturn(Optional.of(owner));
var result = service.authenticate(raw);
List<String> auths =
result.get().authorities().stream().map(GrantedAuthority::getAuthority).toList();
assertThat(auths).contains(Role.ADMIN.getRoleId());
}
@Test
@DisplayName("revokeMigratedKey disables the shadow row so a rotated legacy key stops working")
void revokeMigratedKeyRevokesRow() {
String raw = "raw-9";
ApiKey shadow = key(9, 1, true, null);
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
.thenReturn(Optional.of(shadow));
service.revokeMigratedKey(raw);
assertThat(shadow.isEnabled()).isFalse();
assertThat(shadow.getRevokedAt()).isNotNull();
verify(apiKeyRepository).save(shadow);
}
}
@@ -1,38 +0,0 @@
package stirling.software.proprietary.security.service;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@DisplayName("ApiKeyHasher")
class ApiKeyHasherTest {
@Test
@DisplayName("generated keys are unique, prefixed, and hash deterministically")
void generateAndHash() {
String a = ApiKeyHasher.generateRawKey();
String b = ApiKeyHasher.generateRawKey();
assertThat(a).startsWith("sk_").isNotEqualTo(b);
// Same input hashes the same; SHA-256 hex is 64 chars.
assertThat(ApiKeyHasher.hash(a)).isEqualTo(ApiKeyHasher.hash(a)).hasSize(64);
assertThat(ApiKeyHasher.hash(a)).isNotEqualTo(ApiKeyHasher.hash(b));
}
@Test
@DisplayName("hash never returns the raw key")
void hashHidesRaw() {
String raw = ApiKeyHasher.generateRawKey();
assertThat(ApiKeyHasher.hash(raw)).isNotEqualTo(raw);
}
@Test
@DisplayName("display prefix is a short non-secret leading fragment")
void displayPrefix() {
String raw = ApiKeyHasher.generateRawKey();
String prefix = ApiKeyHasher.displayPrefix(raw);
assertThat(prefix).hasSize(11).startsWith("sk_");
assertThat(raw).startsWith(prefix);
}
}
@@ -1,198 +0,0 @@
package stirling.software.proprietary.security.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest;
import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto;
import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKey;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository;
import stirling.software.proprietary.security.repository.ApiKeyRepository;
@ExtendWith(MockitoExtension.class)
@DisplayName("ApiKeyManagementService")
class ApiKeyManagementServiceTest {
@Mock private ApiKeyRepository apiKeyRepository;
@Mock private ApiKeyDailyUsageRepository usageRepository;
@Mock private UserRepository userRepository;
@Mock private UserService userService;
@Mock private ApiKeyLegacyMigrator legacyMigrator;
@InjectMocks private ApiKeyManagementService service;
private User caller;
@BeforeEach
void setUp() {
caller = new User();
caller.setId(1L);
caller.setUsername("alice");
lenient().when(userService.getCurrentUsername()).thenReturn("alice");
lenient()
.when(userService.findByUsernameIgnoreCase("alice"))
.thenReturn(Optional.of(caller));
lenient().when(apiKeyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
lenient().when(usageRepository.countForDayByIds(any(), anyLong())).thenReturn(List.of());
lenient().when(usageRepository.sumSinceByIds(any(), anyLong())).thenReturn(List.of());
}
private ApiKey personalKey(long id, long ownerId) {
return ApiKey.builder()
.id(id)
.name("Key " + id)
.keyHash("hash" + id)
.prefix("sk_demo0000")
.ownerUserId(ownerId)
.enabled(true)
.createdAt(Instant.now())
.build();
}
// ---- migration safety ---------------------------------------------------
@Test
@DisplayName("an existing legacy key migrates to an owner-only row")
void legacyKeyMigratesAsPersonal() {
caller.setApiKey("legacy-raw-key");
when(apiKeyRepository.existsByKeyHash(ApiKeyHasher.hash("legacy-raw-key")))
.thenReturn(false);
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)).thenReturn(List.of());
service.listVisibleKeys();
// Migration insert is isolated in its own transaction (ApiKeyLegacyMigrator).
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
verify(legacyMigrator).insertMigratedKey(saved.capture());
ApiKey migrated = saved.getValue();
assertThat(migrated.getOwnerUserId()).isEqualTo(1L);
}
@Test
@DisplayName("migration is idempotent - an already-migrated legacy key is not re-saved")
void legacyKeyMigrationIdempotent() {
caller.setApiKey("legacy-raw-key");
when(apiKeyRepository.existsByKeyHash(ApiKeyHasher.hash("legacy-raw-key")))
.thenReturn(true);
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)).thenReturn(List.of());
service.listVisibleKeys();
verify(legacyMigrator, never()).insertMigratedKey(any());
}
// ---- personal isolation -------------------------------------------------
@Test
@DisplayName("listing scopes keys to the caller by owner id")
void personalKeysScopedToOwner() {
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L))
.thenReturn(List.of(personalKey(10, 1L)));
PortalApiKeysResponse res = service.listVisibleKeys();
assertThat(res.keys()).singleElement().satisfies(k -> assertThat(k.id()).isEqualTo("10"));
// Isolation: the query is keyed by the caller's id, never a broad scan.
verify(apiKeyRepository).findByOwnerUserIdOrderByCreatedAtDesc(1L);
}
// ---- creation -----------------------------------------------------------
@Test
@DisplayName("a user creates a personal key and gets a one-time secret")
void createPersonalKey() {
CreatedApiKeyDto created = service.createKey(new CreateApiKeyRequest("My key"));
assertThat(created.secret()).startsWith("sk_");
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
verify(apiKeyRepository).save(saved.capture());
assertThat(saved.getValue().getOwnerUserId()).isEqualTo(1L);
assertThat(saved.getValue().getName()).isEqualTo("My key");
}
@Test
@DisplayName("rejects an over-long key name")
void rejectsLongName() {
String longName = "a".repeat(101);
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest(longName)))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("characters or fewer");
verify(apiKeyRepository, never()).save(any());
}
@Test
@DisplayName("rejects a blank key name")
void rejectsBlankName() {
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest(" ")))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("required");
verify(apiKeyRepository, never()).save(any());
}
@Test
@DisplayName("rejects creating a key past the per-user active-key cap")
void rejectsPastActiveKeyCap() {
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L))
.thenReturn(java.util.Collections.nCopies(50, personalKey(100, 1L)));
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest("One too many")))
.isInstanceOf(ResponseStatusException.class);
verify(apiKeyRepository, never()).save(any());
}
// ---- revocation ---------------------------------------------------------
@Test
@DisplayName("owner revokes their key and the legacy column is cleared")
void revokePersonalClearsLegacy() {
caller.setApiKey("legacy-raw-key");
ApiKey legacyRow = personalKey(30, 1L);
legacyRow.setKeyHash(ApiKeyHasher.hash("legacy-raw-key"));
when(apiKeyRepository.findById(30L)).thenReturn(Optional.of(legacyRow));
when(userRepository.findById(1L)).thenReturn(Optional.of(caller));
service.revokeKey(30L);
assertThat(legacyRow.isEnabled()).isFalse();
assertThat(legacyRow.getRevokedAt()).isNotNull();
assertThat(caller.getApiKey()).isNull();
verify(userRepository).save(caller);
}
@Test
@DisplayName(
"a non-owner cannot revoke someone else's key (404, not 403, so ids can't be probed)")
void revokeForeignKeyForbidden() {
when(apiKeyRepository.findById(31L)).thenReturn(Optional.of(personalKey(31, 999L)));
assertThatThrownBy(() -> service.revokeKey(31L))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404));
verify(apiKeyRepository, never()).save(any());
}
}
@@ -1,88 +0,0 @@
package stirling.software.proprietary.security.service;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* Unit tests for the increment/insert/increment race protocol. {@code @Async} has no proxy in a
* plain Mockito test, so {@code record()} runs inline and is directly testable.
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("ApiKeyUsageRecorder")
class ApiKeyUsageRecorderTest {
private static final long KEY = 7L;
@Mock private ApiKeyUsageWriter writer;
@InjectMocks private ApiKeyUsageRecorder recorder;
@Test
@DisplayName("a null key id is a no-op")
void nullIdIsNoOp() {
recorder.record(null);
verifyNoInteractions(writer);
}
@Test
@DisplayName("row already exists: one increment, never inserts")
void rowExistsFastPath() {
when(writer.increment(eq(KEY), anyLong())).thenReturn(1);
recorder.record(KEY);
verify(writer, times(1)).increment(eq(KEY), anyLong());
verify(writer, never()).tryInsertFirstUse(anyLong(), anyLong());
verify(writer).stampLastUsed(KEY);
}
@Test
@DisplayName("first writer of the day: increment misses, insert wins, no second increment")
void firstWriterInserts() {
when(writer.increment(eq(KEY), anyLong())).thenReturn(0);
when(writer.tryInsertFirstUse(eq(KEY), anyLong())).thenReturn(true);
recorder.record(KEY);
verify(writer, times(1)).increment(eq(KEY), anyLong());
verify(writer).tryInsertFirstUse(eq(KEY), anyLong());
verify(writer).stampLastUsed(KEY);
}
@Test
@DisplayName(
"lost the insert race: falls back to a second increment so the count is not dropped")
void lostInsertRaceReincrements() {
when(writer.increment(eq(KEY), anyLong())).thenReturn(0);
when(writer.tryInsertFirstUse(eq(KEY), anyLong())).thenReturn(false);
recorder.record(KEY);
verify(writer, times(2)).increment(eq(KEY), anyLong());
verify(writer).stampLastUsed(KEY);
}
@Test
@DisplayName("insert throws (rollback-only commit): still re-increments, count not dropped")
void insertThrowsStillReincrements() {
when(writer.increment(eq(KEY), anyLong())).thenReturn(0);
when(writer.tryInsertFirstUse(eq(KEY), anyLong()))
.thenThrow(new RuntimeException("UnexpectedRollbackException"));
recorder.record(KEY);
verify(writer, times(2)).increment(eq(KEY), anyLong());
verify(writer).stampLastUsed(KEY);
}
}
@@ -41,7 +41,6 @@ import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
import stirling.software.proprietary.storage.repository.FileShareRepository;
@@ -77,7 +76,6 @@ class UserServiceMoreTest {
integrationConfigRepository;
@Mock private TeamMembershipService teamMembershipService;
@Mock private ApiKeyAuthenticationService apiKeyAuthenticationService;
@InjectMocks private UserService userService;
@@ -101,8 +99,7 @@ class UserServiceMoreTest {
void getAuthenticationValid() {
User u = user("api");
u.addAuthority(new Authority("ROLE_USER", u));
when(apiKeyAuthenticationService.authenticate("k"))
.thenReturn(Optional.of(new ApiKeyAuthentication(u, null, u.getAuthorities())));
when(userRepository.findByApiKey("k")).thenReturn(Optional.of(u));
assertThat(userService.getAuthentication("k")).isNotNull();
}
@@ -110,7 +107,7 @@ class UserServiceMoreTest {
@Test
@DisplayName("getAuthentication throws when key is unknown")
void getAuthenticationInvalid() {
when(apiKeyAuthenticationService.authenticate("bad")).thenReturn(Optional.empty());
when(userRepository.findByApiKey("bad")).thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.getAuthentication("bad"))
.isInstanceOf(UsernameNotFoundException.class);
@@ -71,7 +71,6 @@ class UserServiceTest {
integrationConfigRepository;
@Mock private TeamMembershipService teamMembershipService;
@Mock private ApiKeyAuthenticationService apiKeyAuthenticationService;
@Spy @InjectMocks private UserService userService;
@@ -197,27 +196,6 @@ class UserServiceTest {
verify(userRepository).save(user);
}
@Test
void addApiKeyToUserRevokesOldMigratedShadowRow() {
User user = new User();
user.setUsername("user");
user.setApiKey("old-secret");
when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user));
when(userRepository.findByApiKey(any())).thenReturn(Optional.empty());
when(userRepository.save(any(User.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
User updated = userService.addApiKeyToUser("user");
// Rotating a legacy key must revoke its migrated api_keys shadow row with the OLD secret,
// and do so before the new key is generated - otherwise the old secret keeps
// authenticating.
org.mockito.InOrder inOrder = inOrder(apiKeyAuthenticationService, userRepository);
inOrder.verify(apiKeyAuthenticationService).revokeMigratedKey("old-secret");
inOrder.verify(userRepository).save(user);
assertNotEquals("old-secret", updated.getApiKey());
}
@Test
void getApiKeyForUserCreatesWhenMissing() {
User user = new User();
@@ -0,0 +1,150 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.AiEngine;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* The config-push bridge is a self-hosted-only feature: it lets the admin UI drive the engine's
* model/key/RAG/limit config. Environment-driven deployments pin {@code
* aiEngine.pushConfigToEngine} false (SaaS does so in application-saas.properties) so the engine
* stays entirely env-controlled - these tests lock in that the processor stays silent then while
* still pushing when it is enabled.
*/
class AiEngineConfigSyncTest {
private ApplicationProperties applicationProperties;
private AiEngineClient aiEngineClient;
private AiEngineConfigSync sync;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties.getAiEngine().setEnabled(true);
applicationProperties.getAiEngine().setPushConfigToEngine(true);
aiEngineClient = mock(AiEngineClient.class);
ObjectMapper objectMapper = JsonMapper.builder().build();
sync = new AiEngineConfigSync(applicationProperties, aiEngineClient, objectMapper);
}
@Test
void startupPushSkippedWhenPushDisabled() throws Exception {
applicationProperties.getAiEngine().setPushConfigToEngine(false);
sync.pushConfigOnStartup();
// Returns synchronously before spawning the push thread, so no interaction ever happens.
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
}
@Test
void startupPushSkippedWhenDisabled() throws Exception {
applicationProperties.getAiEngine().setEnabled(false);
sync.pushConfigOnStartup();
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
}
@Test
void startupPushSentWhenEnabledAndPushOn() throws Exception {
sync.pushConfigOnStartup();
// Push runs on a virtual thread; wait for the single POST to /api/v1/config.
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), anyString(), isNull());
}
@Test
void livePushSkippedWhenPushDisabled() throws Exception {
applicationProperties.getAiEngine().setPushConfigToEngine(false);
sync.pushLiveAfterSave(Map.of("aiEngine.models.provider", "ollama"));
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
}
@Test
void livePushSentForEngineRelevantChangeWhenPushOn() throws Exception {
sync.pushLiveAfterSave(Map.of("aiEngine.models.provider", "ollama"));
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), anyString(), isNull());
}
@Test
void startupPushSerialisesTheEngineWireContract() throws Exception {
// Distinct values so a dropped/renamed field is detectable. Mirrors
// engine/tests/fixtures/processor_config_push.json - keep the two in sync; the engine's
// test_config_contract.py validates that same shape on the receiving side.
AiEngine ai = applicationProperties.getAiEngine();
ai.getModels().setProvider("ollama");
ai.getModels().setSmartModel("smart-model-x");
ai.getModels().setFastModel("fast-model-x");
ai.getModels().setSmartMaxTokens(1111);
ai.getModels().setFastMaxTokens(2222);
ai.getModels().setApiKey("provider-key-abc");
ai.getModels().setBaseUrl("http://engine.example/v1");
ai.getRag().setEmbeddingProvider("custom");
ai.getRag().setEmbeddingModel("embed-model-x");
ai.getRag().setEmbeddingApiKey("embed-key-abc");
ai.getRag().setEmbeddingBaseUrl("http://embed.example/v1");
ai.getRag().setTopK(33);
ai.getRag().setMaxSearches(7);
ai.getLimits().setMaxPages(111);
ai.getLimits().setMaxCharacters(222222);
ai.getLimits().setModelMaxConcurrency(9);
ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
sync.pushConfigOnStartup();
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), body.capture(), isNull());
JsonNode root = JsonMapper.builder().build().readTree(body.getValue());
JsonNode models = root.get("models");
assertEquals("ollama", models.get("provider").asText());
assertEquals("smart-model-x", models.get("smartModel").asText());
assertEquals("fast-model-x", models.get("fastModel").asText());
assertEquals(1111, models.get("smartMaxTokens").asInt());
assertEquals(2222, models.get("fastMaxTokens").asInt());
assertEquals("provider-key-abc", models.get("apiKey").asText());
assertEquals("http://engine.example/v1", models.get("baseUrl").asText());
JsonNode rag = root.get("rag");
assertEquals("custom", rag.get("embeddingProvider").asText());
assertEquals("embed-model-x", rag.get("embeddingModel").asText());
assertEquals("embed-key-abc", rag.get("embeddingApiKey").asText());
assertEquals("http://embed.example/v1", rag.get("embeddingBaseUrl").asText());
assertEquals(33, rag.get("topK").asInt());
assertEquals(7, rag.get("maxSearches").asInt());
JsonNode limits = root.get("limits");
assertEquals(111, limits.get("maxPages").asInt());
assertEquals(222222, limits.get("maxCharacters").asInt());
assertEquals(9, limits.get("modelMaxConcurrency").asInt());
}
@Test
void livePushSkippedForNonEngineRelevantChange() throws Exception {
// features.* is processor-side only; no engine push is warranted.
sync.pushLiveAfterSave(Map.of("aiEngine.features.chat", false));
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
}
}
@@ -0,0 +1,54 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
/**
* Every AI capability entry point calls the matching {@code require*} before reaching the engine.
* These lock in fail-closed behaviour: a 503 when the engine is disabled OR the individual feature
* flag is off, and a clean pass only when both are on.
*/
class AiFeatureGateTest {
private ApplicationProperties props;
private AiFeatureGate gate;
@BeforeEach
void setUp() {
props = new ApplicationProperties();
props.getAiEngine().setEnabled(true); // features default all-on
gate = new AiFeatureGate(props);
}
@Test
void passesWhenEngineEnabledAndFeatureOn() {
assertDoesNotThrow(() -> gate.requireClassify());
assertDoesNotThrow(() -> gate.requireChat());
}
@Test
void throws503WhenFeatureFlagOff() {
props.getAiEngine().getFeatures().setClassify(false);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> gate.requireClassify());
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
}
@Test
void throws503WhenEngineDisabledEvenIfFeatureOn() {
props.getAiEngine().setEnabled(false); // feature flag still true
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> gate.requireChat());
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
}
}
@@ -70,18 +70,4 @@ class PortalDocumentsServiceTest {
assertThat(doc.getProduct()).isEqualTo("API");
assertThat(doc.getSource()).isEqualTo("API integration");
}
@Test
void apiDocumentIsAttributedToItsNamedKey() {
PortalReviewDocumentDto doc =
onlyDoc(
"{\"path\":\"/api/v1/misc/compress-pdf\",\"__origin\":\"API\","
+ "\"__apiKeyLabel\":\"Production ingest (sk_demo0000)\","
+ "\"files\":[{\"name\":\"a.pdf\",\"type\":\"application/pdf\"}],"
+ "\"statusCode\":200}");
// The specific key label surfaces as the source; product stays "API".
assertThat(doc.getProduct()).isEqualTo("API");
assertThat(doc.getSource()).isEqualTo("API key · Production ingest (sk_demo0000)");
}
}
@@ -18,7 +18,6 @@ import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.slf4j.MDC;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
@@ -44,8 +43,6 @@ import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.model.AmrMethod;
@@ -68,7 +65,6 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
private final SupabaseUserService supabaseUserService;
private final SaasTeamService saasTeamService;
private final JwtDecoder jwtDecoder;
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
private final AuthenticationEntryPoint authenticationEntryPoint =
new BearerTokenAuthenticationEntryPoint();
@@ -77,14 +73,12 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
UserService userService,
SupabaseUserService supabaseUserService,
SaasTeamService saasTeamService,
JwtDecoder jwtDecoder,
ApiKeyAuthenticationService apiKeyAuthenticationService) {
JwtDecoder jwtDecoder) {
this.teamService = teamService;
this.userService = userService;
this.supabaseUserService = supabaseUserService;
this.saasTeamService = saasTeamService;
this.jwtDecoder = jwtDecoder;
this.apiKeyAuthenticationService = apiKeyAuthenticationService;
}
@Override
@@ -92,9 +86,6 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// Start clean so a pooled thread can't inherit a prior request's API-key label.
MDC.remove(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY);
if (isStaticResource(request.getContextPath(), request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
@@ -415,22 +406,16 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
return false;
}
// Resolves the multi-key table then the legacy key, records per-key usage, and yields a
// label for the processor's document-source attribution.
Optional<ApiKeyAuthentication> resolved = apiKeyAuthenticationService.authenticate(apiKey);
if (resolved.isEmpty()) {
Optional<User> user = userService.getUserByApiKey(apiKey);
if (user.isEmpty()) {
throw new InvalidBearerTokenException("Invalid API Key.");
}
User user = resolved.get().user();
userService.trackApiKeyFirstUse(user);
userService.trackApiKeyFirstUse(user.get());
ApiKeyAuthenticationToken authToken =
new ApiKeyAuthenticationToken(user, apiKey, resolved.get().authorities());
new ApiKeyAuthenticationToken(user.get(), apiKey, user.get().getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
if (resolved.get().auditLabel() != null) {
MDC.put(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY, resolved.get().auditLabel());
}
return true;
}
@@ -48,7 +48,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.accountlink.DeviceCredentialAuthenticationFilter;
@@ -70,7 +69,6 @@ public class SupabaseSecurityConfig {
private final SupabaseUserService supabaseUserService;
private final SaasTeamService saasTeamService;
private final ApplicationProperties applicationProperties;
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
@Value("${app.supabase.issuer:}")
private String issuer;
@@ -127,8 +125,7 @@ public class SupabaseSecurityConfig {
userService,
supabaseUserService,
saasTeamService,
jwtDecoder,
apiKeyAuthenticationService),
jwtDecoder),
BearerTokenAuthenticationFilter.class)
.exceptionHandling(
ex ->
@@ -1,6 +1,12 @@
# Stirling-PDF SaaS profile. Pure multi-tenant cloud.
# Activated when the :saas module is on the classpath.
# ---------- AI engine ----------
# The SaaS AI backend is env-driven and reached through the saas AiProxyController, so the
# processor must never push settings-derived config to it. Pin the config-push off; the engine
# stays entirely environment-controlled.
aiEngine.pushConfigToEngine=false
# ---------- Datasource ----------
system.datasource.enableCustomDatabase=true
system.datasource.customDatabaseUrl=${SAAS_DB_URL:}
@@ -1,24 +0,0 @@
-- Named, multi-key personal API keys, plus per-key daily usage.
-- Idempotent: Hibernate ddl-auto=update may already have created these on some deployments.
CREATE TABLE IF NOT EXISTS api_keys (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
key_hash VARCHAR(64) NOT NULL,
prefix VARCHAR(32) NOT NULL,
owner_user_id BIGINT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_key_hash ON api_keys (key_hash);
CREATE INDEX IF NOT EXISTS idx_api_key_owner ON api_keys (owner_user_id);
CREATE TABLE IF NOT EXISTS api_key_daily_usage (
api_key_id BIGINT NOT NULL,
epoch_day BIGINT NOT NULL,
count BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (api_key_id, epoch_day)
);
@@ -58,10 +58,6 @@ class SupabaseAuthenticationFilterMoreTest {
@Mock private SaasTeamService saasTeamService;
@Mock private JwtDecoder jwtDecoder;
@Mock
private stirling.software.proprietary.security.service.ApiKeyAuthenticationService
apiKeyAuthenticationService;
private SupabaseAuthenticationFilter filter;
private MockHttpServletRequest request;
private MockHttpServletResponse response;
@@ -72,12 +68,7 @@ class SupabaseAuthenticationFilterMoreTest {
SecurityContextHolder.clearContext();
filter =
new SupabaseAuthenticationFilter(
teamService,
userService,
supabaseUserService,
saasTeamService,
jwtDecoder,
apiKeyAuthenticationService);
teamService, userService, supabaseUserService, saasTeamService, jwtDecoder);
request = new MockHttpServletRequest();
response = new MockHttpServletResponse();
chain = new MockFilterChain();
@@ -243,12 +234,7 @@ class SupabaseAuthenticationFilterMoreTest {
@DisplayName("returns true and skips lookup when an api key sets an authenticated context")
void apiKeyValidStillAuthenticates() throws Exception {
User user = newUser("alice");
when(apiKeyAuthenticationService.authenticate("k1"))
.thenReturn(
Optional.of(
new stirling.software.proprietary.security.service
.ApiKeyAuthenticationService.ApiKeyAuthentication(
user, null, user.getAuthorities())));
when(userService.getUserByApiKey("k1")).thenReturn(Optional.of(user));
request.setRequestURI("/api/v1/something");
request.setMethod("POST");
@@ -48,10 +48,6 @@ class SupabaseAuthenticationFilterTest {
@Mock private stirling.software.saas.service.SaasTeamService saasTeamService;
@Mock private JwtDecoder jwtDecoder;
@Mock
private stirling.software.proprietary.security.service.ApiKeyAuthenticationService
apiKeyAuthenticationService;
private SupabaseAuthenticationFilter filter;
private MockHttpServletRequest request;
private MockHttpServletResponse response;
@@ -62,12 +58,7 @@ class SupabaseAuthenticationFilterTest {
SecurityContextHolder.clearContext();
filter =
new SupabaseAuthenticationFilter(
teamService,
userService,
supabaseUserService,
saasTeamService,
jwtDecoder,
apiKeyAuthenticationService);
teamService, userService, supabaseUserService, saasTeamService, jwtDecoder);
request = new MockHttpServletRequest();
response = new MockHttpServletResponse();
chain = new MockFilterChain();
@@ -94,12 +85,7 @@ class SupabaseAuthenticationFilterTest {
void apiKeyHeaderPopulatesSecurityContext() throws Exception {
User user = newUser("alice");
user.setApiKey("api-key-123");
when(apiKeyAuthenticationService.authenticate("api-key-123"))
.thenReturn(
Optional.of(
new stirling.software.proprietary.security.service
.ApiKeyAuthenticationService.ApiKeyAuthentication(
user, null, user.getAuthorities())));
when(userService.getUserByApiKey("api-key-123")).thenReturn(Optional.of(user));
request.setRequestURI("/api/v1/something");
request.setMethod("POST");
@@ -117,7 +103,7 @@ class SupabaseAuthenticationFilterTest {
@Test
void invalidApiKeyTriggers401() throws Exception {
when(apiKeyAuthenticationService.authenticate("nope")).thenReturn(Optional.empty());
when(userService.getUserByApiKey("nope")).thenReturn(Optional.empty());
request.setRequestURI("/api/v1/something");
request.setMethod("POST");
@@ -43,18 +43,9 @@ class SupabaseSecurityConfigMoreTest {
@Mock private SupabaseUserService supabaseUserService;
@Mock private SaasTeamService saasTeamService;
@Mock
private stirling.software.proprietary.security.service.ApiKeyAuthenticationService
apiKeyAuthenticationService;
private SupabaseSecurityConfig config(ApplicationProperties props) {
return new SupabaseSecurityConfig(
userService,
teamService,
supabaseUserService,
saasTeamService,
props,
apiKeyAuthenticationService);
userService, teamService, supabaseUserService, saasTeamService, props);
}
@Nested
@@ -21,7 +21,6 @@ import org.springframework.security.core.context.SecurityContextHolder;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.model.TeamMembership;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.proprietary.security.service.UserService;
@@ -108,27 +107,6 @@ class TeamSecurityExpressionsTest {
assertNull(expressions().currentUserTeamId());
}
private User leaderUser() {
User leader = new User();
leader.setId(USER_ID);
Team team = new Team();
team.setId(TEAM_ID);
leader.setTeam(team);
return leader;
}
@Test
void apiKeyOfALeaderStillLeads() {
// A key acts as the owner; if they lead the team, the key leads.
SecurityContextHolder.getContext()
.setAuthentication(
new ApiKeyAuthenticationToken(leaderUser(), "sk_personal", List.of()));
when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID))
.thenReturn(Optional.of(membershipWithRole(TeamRole.LEADER)));
assertTrue(expressions().isCurrentUserTeamLeader());
}
@Test
void unauthenticatedIsNotLeader() {
// No authentication set on the context.
+5
View File
@@ -88,3 +88,8 @@ STIRLING_LOG_FILE=
# Use when diagnosing worker stalls: a hung call shows a "Request" line with
# no matching "Response" line. Noisy; leave off in normal use.
STIRLING_HTTP_DEBUG=false
# Allow the Java processor to push admin-configured AI settings (model,
# credentials, limits) to POST /api/v1/config at startup. Set false in
# environment-driven deployments so the environment is the single source of truth.
#STIRLING_ALLOW_CONFIG_PUSH=true
+1
View File
@@ -4,6 +4,7 @@ version = "0.1.0"
description = "AI Document Engine"
requires-python = ">=3.13"
dependencies = [
"cryptography>=44.0.0",
"fastapi>=0.116.0",
"jinja2>=3.1.0",
"pgvector>=0.3.6",
@@ -15,6 +15,7 @@ from __future__ import annotations
from pydantic import Field
from pydantic_ai import Agent
from stirling.agents.output_mode import output_retries
from stirling.contracts import (
MathAuditorToolReportArtifact,
OrchestratorRequest,
@@ -68,6 +69,10 @@ class MathIntentClassifier:
self._agent: Agent[None, _MathIntentDecision] = Agent(
model=runtime.fast_model,
output_type=_MathIntentDecision,
# Local models emit valid structured output only intermittently (they
# sometimes wrap the JSON in prose); a few extra retries make this
# hot-path classifier reliable. No-op for real providers.
retries=output_retries(runtime.settings.chat_provider),
system_prompt=_MATH_INTENT_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
+85 -2
View File
@@ -2,12 +2,14 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import assert_never
from typing import Literal, assert_never
from pydantic import ConfigDict, Field
from pydantic_ai import Agent
from pydantic_ai.output import ToolOutput
from pydantic_ai.output import NativeOutput, ToolOutput
from pydantic_ai.tools import RunContext
from stirling.agents.output_mode import output_retries, uses_tool_output
from stirling.agents.pdf_create import PdfCreateAgent
from stirling.agents.pdf_edit import PdfEditAgent
from stirling.agents.pdf_questions import PdfQuestionAgent
@@ -27,6 +29,7 @@ from stirling.contracts import (
format_file_names,
)
from stirling.contracts.pdf_create import PdfCreateOrchestrateResponse
from stirling.models import ApiModel
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
@@ -38,6 +41,38 @@ class OrchestratorDeps:
request: OrchestratorRequest
# Enum-style routing used for Ollama/custom (OpenAI-compatible local) models. The
# function-ToolOutput delegates below take no arguments (they read everything from
# ctx), but local models reliably try to pass the user's message as tool args, which
# the zero-arg delegate schemas reject. Having the model pick a capability by name and
# dispatching in Python (via the same _run_* methods used on resume) sidesteps that.
_RouteCapability = Literal["pdf_edit", "pdf_question", "user_spec", "pdf_review", "pdf_create", "unsupported"]
class _RouteDecision(ApiModel):
# Local models routinely add stray tool args (the filename, the question echoed back)
# and send null for optional fields; tolerate both so routing never fails on cosmetics.
model_config = ConfigDict(extra="ignore")
capability: _RouteCapability
message: str | None = Field(
default=None,
description="Only for capability='unsupported': a short, helpful message to show the user.",
)
_ROUTER_SYSTEM_PROMPT = (
"You are the top-level router. Choose exactly one capability that best handles the request:\n"
"- pdf_edit: modify or convert one or more attached PDFs.\n"
"- pdf_question: answer questions about the contents of the attached PDFs.\n"
"- user_spec: create or define an agent spec.\n"
"- pdf_review: return the PDF with review comments/annotations attached.\n"
"- pdf_create: generate a NEW document from scratch (invoice, report, letter) - no input file.\n"
"- unsupported: none of the above fit, or the user asks about the assistant itself; put a "
"helpful message in 'message'.\n"
"Respond with the capability and (only for unsupported) a message."
)
class OrchestratorAgent:
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
@@ -86,6 +121,9 @@ class OrchestratorAgent:
description="Return this when none of the delegate outputs fit the request.",
),
],
# Local models (Ollama/custom) pick a delegate less reliably; give the
# routing a few extra validation retries. No-op for real providers.
retries=output_retries(runtime.settings.chat_provider),
deps_type=OrchestratorDeps,
system_prompt=(
"You are the top-level orchestrator. "
@@ -103,6 +141,24 @@ class OrchestratorAgent:
),
model_settings=runtime.fast_model_settings,
)
# Local models can't drive the zero-arg function-delegate routing above, so
# route them by capability name and dispatch in Python instead.
self._route_via_enum = uses_tool_output(runtime.settings.chat_provider)
# The router has no tools of its own, so NativeOutput (a direct structured
# response) works on Ollama here - unlike tool-using agents, which need
# ToolOutput. A single output tool would tempt a local model to answer in
# plain text and never call it ("include your response in a tool call").
self._router = (
Agent(
model=runtime.fast_model,
output_type=NativeOutput([_RouteDecision]),
retries=output_retries(runtime.settings.chat_provider),
system_prompt=_ROUTER_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
if self._route_via_enum
else None
)
async def handle(self, request: OrchestratorRequest) -> OrchestratorResponse:
logger.info(
@@ -114,6 +170,8 @@ class OrchestratorAgent:
)
if request.resume_with is not None:
return await self._resume(request, request.resume_with)
if self._router is not None:
return await self._route_and_dispatch(request)
result = await self.agent.run(
self._build_prompt(request),
deps=OrchestratorDeps(runtime=self.runtime, request=request),
@@ -121,6 +179,31 @@ class OrchestratorAgent:
logger.info("[orchestrator] routed -> %s", type(result.output).__name__)
return result.output
async def _route_and_dispatch(self, request: OrchestratorRequest) -> OrchestratorResponse:
"""Local-model routing: pick a capability by name, then dispatch in Python."""
assert self._router is not None
result = await self._router.run(self._build_prompt(request))
decision = result.output
logger.info("[orchestrator] enum-routed -> %s", decision.capability)
match decision.capability:
case "pdf_edit":
return await self._run_pdf_edit(request)
case "pdf_question":
return await self._run_pdf_question(request)
case "user_spec":
return await self._run_agent_draft(request)
case "pdf_review":
return await self._run_pdf_review(request)
case "pdf_create":
return await self._run_pdf_create(request)
case "unsupported":
return UnsupportedCapabilityResponse(
capability="orchestrate",
message=decision.message or "I can't help with that request.",
)
case _ as unreachable:
assert_never(unreachable)
async def _resume(self, request: OrchestratorRequest, capability: SupportedCapability) -> OrchestratorResponse:
"""Fast-path to get back to the correct endpoint without having to call AI.
+46
View File
@@ -0,0 +1,46 @@
"""Provider-aware structured-output strategy.
OpenAI-compatible local models (Ollama, and 'custom' OpenAI-compatible endpoints)
block tool-calling whenever a native json-schema ``response_format`` is set: the
API constrains every completion to the output schema, so the model can never emit
a tool-call turn. Agents that combine structured output with tools (RAG search,
whole-document read, etc.) therefore get an ungrounded answer - the model fills the
schema directly instead of calling a retrieval tool.
Delivering the structured result via a tool call (:class:`ToolOutput`) sidesteps
this: it's all tool-calling, which these endpoints handle, so the model can call a
retrieval tool and then the output tool. Real providers (Anthropic, OpenAI) keep
:class:`NativeOutput`, which is unaffected and preferred there.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from pydantic_ai.output import NativeOutput, ToolOutput
# Providers whose OpenAI-compatible endpoint needs tool-delivered structured output.
_TOOL_OUTPUT_PROVIDERS = frozenset({"ollama", "custom"})
def uses_tool_output(chat_provider: str) -> bool:
return chat_provider in _TOOL_OUTPUT_PROVIDERS
def structured_output(output_types: Sequence[Any], *, chat_provider: str) -> Any:
"""Pick a structured-output spec compatible with the active chat provider.
Ollama/custom deliver each output variant via a :class:`ToolOutput`; every other
provider uses a single :class:`NativeOutput` over the variants.
"""
types = list(output_types)
if uses_tool_output(chat_provider):
return [ToolOutput(t) for t in types]
return NativeOutput(types)
def output_retries(chat_provider: str, *, native: int = 1, tool: int = 6) -> int:
"""Local models delivering via ToolOutput need more output-validation retries -
small models produce valid complex/nested structured output only intermittently."""
return tool if uses_tool_output(chat_provider) else native
+10 -2
View File
@@ -3,10 +3,10 @@ from __future__ import annotations
import logging
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.agents.contradiction import ContradictionCapability, ContradictionDetector
from stirling.agents.math_presentation import MathIntentClassifier, extract_math_verdict
from stirling.agents.output_mode import output_retries, structured_output
from stirling.agents.shared import ChunkedReasoner, WholeDocReaderCapability
from stirling.contracts import (
AiFile,
@@ -196,9 +196,17 @@ class PdfQuestionAgent:
files=request.files,
principals=principals,
)
# Ollama/custom (OpenAI-compatible local) models block tool-calling under a
# native json-schema response format, so deliver the structured result via a
# tool call instead - otherwise the model never calls the retrieval tools and
# answers ungrounded. Real providers keep NativeOutput. See agents.output_mode.
provider = self.runtime.settings.chat_provider
agent = Agent(
model=self.runtime.smart_model,
output_type=NativeOutput([PdfQuestionAnswerResponse, PdfQuestionNotFoundResponse]),
output_type=structured_output(
[PdfQuestionAnswerResponse, PdfQuestionNotFoundResponse], chat_provider=provider
),
retries=output_retries(provider),
system_prompt=PDF_QUESTION_SYSTEM_PROMPT,
# pydantic-ai accepts a list of (string-or-callable) instruction sources;
# it resolves each at run time and concatenates them for the model.
+59 -27
View File
@@ -3,28 +3,20 @@ from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi import Depends, FastAPI, Request
from pydantic_ai import Agent
from pydantic_ai.models import Model
from pydantic_ai.models.instrumented import InstrumentationSettings
from stirling.agents import (
DocumentClassifierAgent,
ExecutionPlanningAgent,
OrchestratorAgent,
PdfEditAgent,
PdfQuestionAgent,
UserSpecAgent,
)
from stirling.agents.ledger import MathAuditorAgent
from stirling.agents.pdf_comment import PdfCommentAgent
from stirling.api.bootstrap import apply_app_state, build_app_state
from stirling.api.dependencies import enforce_required_user_id
from stirling.api.engine_auth import EngineSharedSecretMiddleware
from stirling.api.middleware import UserIdMiddleware
from stirling.api.routes import (
agent_capabilities_router,
agent_draft_router,
config_router,
document_classifier_router,
document_router,
execution_router,
@@ -34,10 +26,12 @@ from stirling.api.routes import (
pdf_edit_router,
pdf_question_router,
)
from stirling.api.routes.config import CONFIG_APPLY_ERRORS, resolve_and_apply
from stirling.config import AppSettings, load_settings
from stirling.config.config_cache import load_config
from stirling.contracts import HealthResponse
from stirling.documents import DocumentService
from stirling.services import build_runtime, setup_posthog_tracking
from stirling.documents import DocumentService, EmbeddingService
from stirling.services import setup_posthog_tracking
logger = logging.getLogger(__name__)
@@ -83,22 +77,52 @@ def _load_startup_settings(fast_api: FastAPI) -> AppSettings:
return load_settings()
def _restore_cached_config(
settings: AppSettings,
) -> tuple[AppSettings, Model | None, Model | None, EmbeddingService | None]:
"""Restore the last-applied pushed config from the encrypted on-disk cache.
Returns the effective settings plus the pre-built smart/fast models and
embedder to inject into the initial app state. Falls back to the env settings
(all-None) when config push is disabled, no cache exists, or the cached config
can't be applied (bad/unavailable model) - the cache never crashes boot.
"""
if not settings.allow_config_push:
return settings, None, None, None
cached = load_config()
if cached is None:
return settings, None, None, None
try:
effective, smart_model, fast_model, embedder, notes = resolve_and_apply(settings, cached)
except CONFIG_APPLY_ERRORS:
logger.warning("Cached AI config could not be applied; falling back to env settings", exc_info=True)
return settings, None, None, None
logger.info(
"Restored cached AI config: smart_model=%s fast_model=%s%s",
effective.smart_model_name,
effective.fast_model_name,
f"; {'; '.join(notes)}" if notes else "",
)
return effective, smart_model, fast_model, embedder
@asynccontextmanager
async def lifespan(fast_api: FastAPI):
# Load env vars on startup so we can immediately crash if required env vars aren't set
settings = _load_startup_settings(fast_api)
runtime = build_runtime(settings)
fast_api.state.settings = settings
fast_api.state.runtime = runtime
fast_api.state.orchestrator_agent = OrchestratorAgent(runtime)
fast_api.state.pdf_edit_agent = PdfEditAgent(runtime)
fast_api.state.pdf_question_agent = PdfQuestionAgent(runtime)
fast_api.state.user_spec_agent = UserSpecAgent(runtime)
fast_api.state.execution_planning_agent = ExecutionPlanningAgent(runtime)
fast_api.state.math_auditor_agent = MathAuditorAgent(runtime)
fast_api.state.pdf_comment_agent = PdfCommentAgent(runtime)
fast_api.state.document_classifier_agent = DocumentClassifierAgent(runtime)
tracer_provider = setup_posthog_tracking(settings)
# Precedence: env < persisted cache < live push. Restore the last-applied pushed
# config unless config push is disabled (then env is the single source of truth).
effective, smart_model, fast_model, embedder = _restore_cached_config(settings)
app_state = build_app_state(
effective,
fast_model=fast_model,
smart_model=smart_model,
embedder=embedder,
)
fast_api.state.settings = effective
apply_app_state(fast_api.state, app_state)
runtime = app_state.runtime
tracer_provider = setup_posthog_tracking(effective)
if tracer_provider:
Agent.instrument_all(InstrumentationSettings(tracer_provider=tracer_provider))
reaper_task = asyncio.create_task(
@@ -135,10 +159,18 @@ app.include_router(ledger_router, dependencies=_user_gate)
app.include_router(pdf_comments_router, dependencies=_user_gate)
app.include_router(agent_capabilities_router, dependencies=_user_gate)
app.include_router(document_classifier_router, dependencies=_user_gate)
# Config push is a system/admin sync from the Java processor with no X-User-Id, so
# it is guarded by the X-Engine-Auth shared secret (global middleware) and the
# allow_config_push flag only, deliberately NOT the per-user identity gate.
app.include_router(config_router)
@app.get("/health", response_model=HealthResponse)
async def healthcheck(settings: Annotated[AppSettings, Depends(load_settings)]) -> HealthResponse:
async def healthcheck(http_request: Request) -> HealthResponse:
# Report the LIVE config (env < cache < push) held on app.state, not the
# boot-time env cache, so an admin "Test connection" check shows the model
# actually in use after a config push. Falls back to env if state isn't up yet.
settings: AppSettings = getattr(http_request.app.state, "settings", None) or load_settings()
return HealthResponse(
status="ok",
smart_model=settings.smart_model_name,
+86
View File
@@ -0,0 +1,86 @@
"""Assemble the runtime + every agent into a single swappable bundle.
Both the lifespan startup and the config-push endpoint build the same object
graph: one :class:`AppRuntime` plus one agent instance per flow, each of which
captures ``runtime.smart_model``/``runtime.fast_model`` at construction. To change
models at runtime the whole bundle has to be rebuilt and swapped into
``app.state`` atomically, which is what :func:`build_app_state` /
:func:`apply_app_state` exist for.
"""
from __future__ import annotations
from dataclasses import dataclass, fields
from typing import Any
from pydantic_ai.models import Model
from stirling.agents import (
DocumentClassifierAgent,
ExecutionPlanningAgent,
OrchestratorAgent,
PdfEditAgent,
PdfQuestionAgent,
UserSpecAgent,
)
from stirling.agents.ledger import MathAuditorAgent
from stirling.agents.pdf_comment import PdfCommentAgent
from stirling.config import AppSettings
from stirling.documents import DocumentService, EmbeddingService
from stirling.services import AppRuntime, build_runtime
@dataclass(frozen=True)
class AppState:
"""Every object the lifespan assigns onto ``fast_api.state``."""
runtime: AppRuntime
orchestrator_agent: OrchestratorAgent
pdf_edit_agent: PdfEditAgent
pdf_question_agent: PdfQuestionAgent
user_spec_agent: UserSpecAgent
execution_planning_agent: ExecutionPlanningAgent
math_auditor_agent: MathAuditorAgent
pdf_comment_agent: PdfCommentAgent
document_classifier_agent: DocumentClassifierAgent
def build_app_state(
settings: AppSettings,
*,
documents: DocumentService | None = None,
fast_model: Model | None = None,
smart_model: Model | None = None,
embedder: EmbeddingService | None = None,
) -> AppState:
"""Build the runtime and every agent from ``settings``.
The keyword arguments are threaded straight to :func:`build_runtime` so a
config-push rebuild can reuse the live document store and inject already
validated models (and, on a cache-restore boot, a pre-built embedder); all
None reproduces the original startup path.
"""
runtime = build_runtime(
settings,
documents=documents,
fast_model=fast_model,
smart_model=smart_model,
embedder=embedder,
)
return AppState(
runtime=runtime,
orchestrator_agent=OrchestratorAgent(runtime),
pdf_edit_agent=PdfEditAgent(runtime),
pdf_question_agent=PdfQuestionAgent(runtime),
user_spec_agent=UserSpecAgent(runtime),
execution_planning_agent=ExecutionPlanningAgent(runtime),
math_auditor_agent=MathAuditorAgent(runtime),
pdf_comment_agent=PdfCommentAgent(runtime),
document_classifier_agent=DocumentClassifierAgent(runtime),
)
def apply_app_state(state: Any, app_state: AppState) -> None:
"""Copy every field of ``app_state`` onto a Starlette ``app.state`` object."""
for field in fields(app_state):
setattr(state, field.name, getattr(app_state, field.name))
@@ -1,5 +1,6 @@
from .agent_capabilities import router as agent_capabilities_router
from .agent_drafts import router as agent_draft_router
from .config import router as config_router
from .document_classifier import router as document_classifier_router
from .documents import router as document_router
from .execution import router as execution_router
@@ -12,6 +13,7 @@ from .pdf_questions import router as pdf_question_router
__all__ = [
"agent_capabilities_router",
"agent_draft_router",
"config_router",
"document_classifier_router",
"document_router",
"execution_router",
+251
View File
@@ -0,0 +1,251 @@
from __future__ import annotations
import ipaddress
import logging
from fastapi import APIRouter, HTTPException, Request, status
from openai import OpenAIError
from pydantic_ai.exceptions import UserError
from pydantic_ai.models import Model
from stirling.api.bootstrap import apply_app_state, build_app_state
from stirling.config import AppSettings
from stirling.config.config_cache import save_config
from stirling.contracts import ConfigApplyResponse, ConfigPushRequest
from stirling.documents import EmbeddingService
from stirling.services import AppRuntime
from stirling.services.runtime import _build_model, validate_structured_output_support
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/config", tags=["config"])
# Model/provider construction + validation failures. The HTTP route maps these to a
# 400 (no swap); boot catches them to fall back to env when a cached config is bad.
CONFIG_APPLY_ERRORS = (ValueError, UserError, OpenAIError)
_REINDEX_NOTE = (
"Embedding model changed; existing indexed documents were embedded with the previous model and "
"must be re-indexed. If the embedding dimensionality changed, re-ingest before searching."
)
def _strip_provider_prefix(model_name: str) -> str:
"""Drop a leading ``provider:`` from an env model string ("anthropic:x" -> "x").
Only used when falling back to the env-configured name under an explicit
pushed provider; a bare pushed name (which may itself contain ``:`` such as
"llama3:8b") is used verbatim and never passed through here.
"""
_, sep, rest = model_name.partition(":")
return rest if sep else model_name
def _compose_embedding_model(provider: str, model: str) -> str:
"""Compose the engine's ``provider:model`` embedding string from pushed parts."""
provider = provider.strip()
return f"{provider}:{model}" if provider else model
def _split_embedding_ref(ref: str) -> tuple[str, str]:
"""Split an env embedding string ("voyageai:voyage-4") into (provider, model)."""
provider, sep, model = ref.partition(":")
return (provider, model) if sep else ("", ref)
def _keep(pushed: int | None, current: int) -> int:
"""Return the pushed value, or the current one when the push omitted it."""
return pushed if pushed is not None else current
def _is_loopback_client(request: Request) -> bool:
"""True when the request originates from the local host (127.0.0.0/8, ::1)."""
client = request.client
if client is None:
return False
try:
return ipaddress.ip_address(client.host).is_loopback
except ValueError:
return client.host == "localhost"
def resolve_and_apply(
current: AppSettings,
request: ConfigPushRequest,
) -> tuple[AppSettings, Model, Model, EmbeddingService | None, list[str]]:
"""Resolve a pushed config against the running settings.
Builds and validates the smart/fast models, resolves the scalar overrides,
and builds a new embedder when the embedding config changed. Empty/None pushed
fields keep the current value. Returns the effective settings, the two built
models, an optional new embedder (None when the embedding config is unchanged),
and human-readable notes.
Raises one of :data:`CONFIG_APPLY_ERRORS` if a chosen model/embedder fails to
build or validate; callers decide whether that becomes a 400 (HTTP route) or an
env fallback (boot). It never swaps any live state - the caller owns that.
"""
models = request.models
rag = request.rag
limits = request.limits
notes: list[str] = []
provider = models.provider.strip()
api_key = models.api_key
base_url = models.base_url
use_explicit_provider = bool(provider or api_key or base_url)
if use_explicit_provider:
smart_name = models.smart_model or _strip_provider_prefix(current.smart_model_name)
fast_name = models.fast_model or _strip_provider_prefix(current.fast_model_name)
else:
# No provider/credentials pushed: keep the fully env-driven model strings.
smart_name = models.smart_model or current.smart_model_name
fast_name = models.fast_model or current.fast_model_name
def _build(bare: str) -> Model:
if use_explicit_provider:
return _build_model(bare, provider=provider or None, api_key=api_key or None, base_url=base_url or None)
return _build_model(bare)
smart_model = _build(smart_name)
fast_model = _build(fast_name)
validate_structured_output_support(smart_model, smart_name)
validate_structured_output_support(fast_model, fast_name)
# Scalars: None / empty keep the current value.
smart_max_tokens = _keep(models.smart_max_tokens, current.smart_model_max_tokens)
fast_max_tokens = _keep(models.fast_max_tokens, current.fast_model_max_tokens)
top_k = _keep(rag.top_k, current.rag_default_top_k)
max_searches = _keep(rag.max_searches, current.rag_max_searches)
max_pages = _keep(limits.max_pages, current.max_pages)
max_characters = _keep(limits.max_characters, current.max_characters)
model_max_concurrency = _keep(limits.model_max_concurrency, current.model_max_concurrency)
# Embedding: any non-empty embedding field triggers a rebuild; empty fields fall
# back to the running provider/model/creds so a partial push never clobbers env.
embedding_changed = bool(
rag.embedding_provider.strip() or rag.embedding_model.strip() or rag.embedding_api_key or rag.embedding_base_url
)
rag_embedding_model = current.rag_embedding_model
new_embedder: EmbeddingService | None = None
if embedding_changed:
current_provider, current_model = _split_embedding_ref(current.rag_embedding_model)
embed_provider = rag.embedding_provider.strip() or current_provider
embed_model = rag.embedding_model.strip() or current_model
rag_embedding_model = _compose_embedding_model(embed_provider, embed_model)
new_embedder = EmbeddingService(
model_name=embed_model,
chunk_size=current.rag_chunk_size,
chunk_overlap=current.rag_chunk_overlap,
provider=embed_provider or None,
api_key=rag.embedding_api_key or None,
base_url=rag.embedding_base_url or None,
)
notes.append(_REINDEX_NOTE)
effective = current.model_copy(
update={
"chat_provider": provider,
"smart_model_name": smart_name,
"fast_model_name": fast_name,
"smart_model_max_tokens": smart_max_tokens,
"fast_model_max_tokens": fast_max_tokens,
"rag_embedding_model": rag_embedding_model,
"rag_default_top_k": top_k,
"rag_max_searches": max_searches,
"max_pages": max_pages,
"max_characters": max_characters,
"model_max_concurrency": model_max_concurrency,
}
)
return effective, smart_model, fast_model, new_embedder, notes
@router.post("", response_model=ConfigApplyResponse)
async def apply_config(request: ConfigPushRequest, http_request: Request) -> ConfigApplyResponse:
"""Apply admin-pushed AI settings by rebuilding the runtime + agents in place.
Gated by the X-Engine-Auth shared secret (global middleware) plus the
``allow_config_push`` flag. Empty credential/model fields keep the engine's
env-configured values. Returns 403 when config push is disabled, 400 (without
swapping state) when a chosen model fails to build/validate. On success the
config is also persisted (encrypted) so it survives an engine restart.
"""
app = http_request.app
current: AppSettings = app.state.settings
if not current.allow_config_push:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Config push is disabled on this deployment (STIRLING_ALLOW_CONFIG_PUSH is false).",
)
# Secure-by-default for this sensitive endpoint. When a shared secret is set the
# global middleware has already authenticated the caller (only the processor has the
# secret). When NO secret is set, only trust a loopback caller - a remote party must
# never be able to push a config unauthenticated, since a pushed base_url/model could
# repoint the engine to exfiltrate document content.
if not current.engine_shared_secret and not _is_loopback_client(http_request):
client_host = http_request.client.host if http_request.client else "unknown"
logger.warning("Rejected config push from non-local caller %s with no shared secret set", client_host)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
"Config push from a non-local caller requires STIRLING_ENGINE_SHARED_SECRET"
" to be set on both the engine and the processor."
),
)
runtime: AppRuntime = app.state.runtime
try:
effective, smart_model, fast_model, new_embedder, notes = resolve_and_apply(current, request)
except CONFIG_APPLY_ERRORS as exc:
# Reject without touching the running config.
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
# Rebuild runtime + every agent, reusing the existing document store, then
# swap the whole bundle onto app.state so in-flight lookups see one config.
new_state = build_app_state(
effective,
documents=runtime.documents,
fast_model=fast_model,
smart_model=smart_model,
)
app.state.settings = effective
apply_app_state(app.state, new_state)
# Retune retrieval breadth on the reused store without rebuilding it.
runtime.documents.default_top_k = effective.rag_default_top_k
if new_embedder is not None:
# Swap the embedder onto the reused DocumentService so we never tear down
# the live vector store / connection pool.
runtime.documents.embedder = new_embedder
# Persist the applied config (encrypted) so it is restored on the next boot.
try:
save_config(request)
except OSError:
logger.warning("Applied AI config but failed to persist the encrypted cache", exc_info=True)
notes.append("Config applied but could not be persisted; it will not survive an engine restart.")
logger.info(
"Applied pushed AI config: provider=%s smart_model=%s fast_model=%s top_k=%s",
request.models.provider.strip() or "<env>",
effective.smart_model_name,
effective.fast_model_name,
effective.rag_default_top_k,
)
return ConfigApplyResponse(
status="applied",
provider=request.models.provider.strip(),
smart_model=effective.smart_model_name,
fast_model=effective.fast_model_name,
smart_max_tokens=effective.smart_model_max_tokens,
fast_max_tokens=effective.fast_model_max_tokens,
rag_embedding_model=effective.rag_embedding_model,
rag_top_k=effective.rag_default_top_k,
rag_max_searches=effective.rag_max_searches,
max_pages=effective.max_pages,
max_characters=effective.max_characters,
model_max_concurrency=effective.model_max_concurrency,
notes=notes,
)
+114
View File
@@ -0,0 +1,114 @@
"""Persistent, encrypted cache of the last-applied config-push.
The engine otherwise holds pushed config in RAM only, so a restart reverts to the
env values until the processor pushes again. We persist the last-applied
:class:`ConfigPushRequest` to ``<engine data dir>/ai_config_cache.enc`` (Fernet
encrypted) and reload it on boot so the effective config survives restarts.
Key derivation: when ``STIRLING_ENGINE_SHARED_SECRET`` is set, the Fernet key is
HKDF-SHA256 over that secret (constant salt/info) so no key material touches disk.
Otherwise a random Fernet key is generated once and stored in a sibling
``ai_config_cache.key`` (0600, best-effort) with a one-time warning.
"""
from __future__ import annotations
import base64
import logging
import os
import stat
from pathlib import Path
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from stirling.config.settings import ENGINE_ROOT, load_settings
from stirling.contracts import ConfigPushRequest
logger = logging.getLogger(__name__)
_CACHE_FILENAME = "ai_config_cache.enc"
_KEY_FILENAME = "ai_config_cache.key"
# Constant salt/info so the same shared secret always derives the same Fernet key.
_HKDF_SALT = b"stirling-ai-config-cache/v1/salt"
_HKDF_INFO = b"stirling-ai-config-cache/v1/fernet-key"
_keyfile_warned = False
def _default_data_dir() -> Path:
"""The engine data dir (where the sqlite store lives by default)."""
return ENGINE_ROOT / "data"
def _shared_secret() -> str:
return load_settings().engine_shared_secret
def _derive_key_from_secret(secret: str) -> bytes:
hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=_HKDF_SALT, info=_HKDF_INFO)
return base64.urlsafe_b64encode(hkdf.derive(secret.encode("utf-8")))
def _chmod_600(path: Path) -> None:
# Best-effort owner-only perms; silently ignored where unsupported (e.g. Windows).
try:
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
def _load_or_create_keyfile(data_dir: Path) -> bytes:
global _keyfile_warned
key_path = data_dir / _KEY_FILENAME
if key_path.exists():
return key_path.read_bytes().strip()
key = Fernet.generate_key()
data_dir.mkdir(parents=True, exist_ok=True)
key_path.write_bytes(key)
_chmod_600(key_path)
if not _keyfile_warned:
logger.warning(
"STIRLING_ENGINE_SHARED_SECRET is not set; encrypting the AI config cache with a local"
" keyfile at %s (0600, best-effort). This is modest protection only - set a shared secret"
" for HKDF key derivation in any deployment where the cache must be strongly protected.",
key_path,
)
_keyfile_warned = True
return key
def _fernet(data_dir: Path) -> Fernet:
secret = _shared_secret()
if secret:
return Fernet(_derive_key_from_secret(secret))
return Fernet(_load_or_create_keyfile(data_dir))
def save_config(request: ConfigPushRequest, *, data_dir: Path | None = None) -> None:
"""Encrypt and persist the last-applied pushed config, overwriting any prior file."""
data_dir = data_dir or _default_data_dir()
data_dir.mkdir(parents=True, exist_ok=True)
payload = request.model_dump_json(by_alias=True).encode("utf-8")
token = _fernet(data_dir).encrypt(payload)
(data_dir / _CACHE_FILENAME).write_bytes(token)
def load_config(*, data_dir: Path | None = None) -> ConfigPushRequest | None:
"""Load + decrypt the persisted pushed config.
Returns None (never raises) when the cache is absent, corrupt, or was written
under a different key, so a bad cache can never crash boot.
"""
data_dir = data_dir or _default_data_dir()
cache_path = data_dir / _CACHE_FILENAME
if not cache_path.exists():
return None
try:
token = cache_path.read_bytes()
payload = _fernet(data_dir).decrypt(token)
return ConfigPushRequest.model_validate_json(payload)
except (InvalidToken, ValueError, OSError) as exc:
logger.warning("Ignoring unreadable AI config cache at %s: %s", cache_path, exc)
return None
+11
View File
@@ -25,6 +25,12 @@ class AppSettings(BaseSettings):
smart_model_name: str = Field(validation_alias="STIRLING_SMART_MODEL")
fast_model_name: str = Field(validation_alias="STIRLING_FAST_MODEL")
# Provider backing the active chat models. Empty for env/native providers; set to
# 'ollama'/'custom' by a config push. Agents that combine structured output with
# tools read this to pick a tool-compatible output strategy, because OpenAI-compatible
# local models (Ollama) block tool calls when a native json-schema response format is
# set, so their structured result must be delivered via a tool call instead.
chat_provider: str = Field(default="")
smart_model_max_tokens: int = Field(validation_alias="STIRLING_SMART_MODEL_MAX_TOKENS")
fast_model_max_tokens: int = Field(validation_alias="STIRLING_FAST_MODEL_MAX_TOKENS")
# Process-wide ceiling on concurrent model API calls, shared by both model
@@ -123,6 +129,11 @@ class AppSettings(BaseSettings):
engine_shared_secret: str = Field(default="", validation_alias="STIRLING_ENGINE_SHARED_SECRET")
engine_require_auth: bool = Field(default=False, validation_alias="STIRLING_ENGINE_REQUIRE_AUTH")
# When true, the Java processor may push admin-configured AI settings (model,
# credentials, limits) to POST /api/v1/config at startup. Turn this off in
# environment-driven deployments so the environment is the single source of truth.
allow_config_push: bool = Field(default=True, validation_alias="STIRLING_ALLOW_CONFIG_PUSH")
def _configure_logging(level_name: str, log_file: str, http_debug: bool) -> None:
"""Configure the ``stirling`` logger hierarchy."""
+12
View File
@@ -29,6 +29,13 @@ from .common import (
format_conversation_history,
format_file_names,
)
from .config import (
ConfigApplyResponse,
ConfigLimitsSection,
ConfigModelsSection,
ConfigPushRequest,
ConfigRagSection,
)
from .contradiction import (
Claim,
Contradiction,
@@ -139,6 +146,11 @@ __all__ = [
"Claim",
"CommentSpec",
"CompletedExecutionAction",
"ConfigApplyResponse",
"ConfigLimitsSection",
"ConfigModelsSection",
"ConfigPushRequest",
"ConfigRagSection",
"Contradiction",
"ContradictionReport",
"ContradictionSeverity",
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from pydantic import ConfigDict, Field
from stirling.models import ApiModel
# Tolerate unknown fields so a newer processor pushing a field this engine version
# doesn't know is ignored rather than rejecting the whole push. Overrides only the
# ``extra`` policy from ApiModel; the camelCase alias generator is inherited.
_TOLERANT = ConfigDict(extra="ignore")
class ConfigModelsSection(ApiModel):
"""Model provider + credentials pushed by the Java processor.
Field names on the wire are camelCase (``smartModel`` etc.) via the
:class:`ApiModel` alias generator. Empty ``provider``/``apiKey``/``baseUrl``
or empty model names mean "keep the engine's env-configured value".
"""
model_config = _TOLERANT
provider: str = ""
smart_model: str = ""
fast_model: str = ""
smart_max_tokens: int | None = None
fast_max_tokens: int | None = None
api_key: str = ""
base_url: str = ""
class ConfigRagSection(ApiModel):
model_config = _TOLERANT
embedding_provider: str = ""
embedding_model: str = ""
embedding_api_key: str = ""
# OpenAI-compatible endpoint URL for ollama/custom embedding providers.
# Empty keeps the engine's env value, same convention as the other fields.
embedding_base_url: str = ""
top_k: int | None = None
max_searches: int | None = None
class ConfigLimitsSection(ApiModel):
model_config = _TOLERANT
max_pages: int | None = None
max_characters: int | None = None
model_max_concurrency: int | None = None
class ConfigPushRequest(ApiModel):
"""Admin-configured AI settings pushed at processor startup."""
model_config = _TOLERANT
models: ConfigModelsSection = Field(default_factory=ConfigModelsSection)
rag: ConfigRagSection = Field(default_factory=ConfigRagSection)
limits: ConfigLimitsSection = Field(default_factory=ConfigLimitsSection)
class ConfigApplyResponse(ApiModel):
"""Summary of the effective config after a push. Never echoes credentials."""
status: str
provider: str
smart_model: str
fast_model: str
smart_max_tokens: int
fast_max_tokens: int
rag_embedding_model: str
rag_top_k: int
rag_max_searches: int
max_pages: int
max_characters: int
model_max_concurrency: int
notes: list[str] = Field(default_factory=list)
+38 -1
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
from pydantic_ai import Embedder
from pydantic_ai.embeddings.openai import OpenAIEmbeddingModel
from pydantic_ai.providers.openai import OpenAIProvider
from stirling.documents.chunker import chunk_text
from stirling.documents.store import Document
@@ -12,6 +14,37 @@ from stirling.documents.store import Document
DEFAULT_EMBED_BATCH_SIZE = 256
def _build_embedder(
model_name: str,
*,
provider: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
) -> Embedder:
"""Construct an :class:`Embedder` for ``model_name``.
With no explicit ``provider``/``api_key``/``base_url`` this keeps the original
behaviour: ``model_name`` is the ``provider:model`` string form (e.g.
"voyageai:voyage-4") and credentials come from the provider's native env var.
With an explicit provider (config-push path) ``model_name`` is the bare model
without a prefix. ``voyageai``/``openai`` compose the same string form; the
OpenAI-compatible ``ollama``/``custom`` build an OpenAI embedding model against
``base_url`` (Ollama ignores the key but the SDK still needs a non-empty one).
"""
if not provider and not api_key and not base_url:
return Embedder(model_name)
provider_name = (provider or "").lower()
key = api_key or None
if provider_name in ("voyageai", "openai"):
return Embedder(f"{provider_name}:{model_name}")
if provider_name in ("ollama", "custom"):
openai_provider = OpenAIProvider(base_url=base_url or None, api_key=key or "ollama")
return Embedder(OpenAIEmbeddingModel(model_name, provider=openai_provider))
raise ValueError(f"Unsupported embedding provider {provider!r}.")
class EmbeddingService:
"""Wraps Pydantic AI's Embedder to provide document chunking and embedding."""
@@ -21,8 +54,12 @@ class EmbeddingService:
chunk_size: int = 512,
chunk_overlap: int = 64,
embed_batch_size: int = DEFAULT_EMBED_BATCH_SIZE,
*,
provider: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
) -> None:
self._embedder = Embedder(model_name)
self._embedder = _build_embedder(model_name, provider=provider, api_key=api_key, base_url=base_url)
self._chunk_size = chunk_size
self._chunk_overlap = chunk_overlap
self._embed_batch_size = embed_batch_size
+19
View File
@@ -43,6 +43,25 @@ class DocumentService:
self._store = store
self._default_top_k = default_top_k
@property
def default_top_k(self) -> int:
return self._default_top_k
@default_top_k.setter
def default_top_k(self, value: int) -> None:
# Lets a config-push retune retrieval breadth without rebuilding the store.
self._default_top_k = value
@property
def embedder(self) -> EmbeddingService:
return self._embedder
@embedder.setter
def embedder(self, value: EmbeddingService) -> None:
# Lets a config-push swap the embedding model while keeping the live store
# (and its connection pool). Existing vectors need re-indexing afterwards.
self._embedder = value
async def ingest(
self,
collection: FileId,
+156 -27
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
@@ -12,8 +14,10 @@ from pydantic_ai import RunContext
from pydantic_ai.messages import ModelMessage, ModelResponse
from pydantic_ai.models import Model, ModelRequestParameters, StreamedResponse, infer_model
from pydantic_ai.models.anthropic import AnthropicModel
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.models.wrapper import WrapperModel
from pydantic_ai.providers.anthropic import AnthropicProvider
from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai.settings import ModelSettings
from stirling.config import ENGINE_ROOT, AppSettings, DocumentsBackend
@@ -49,6 +53,79 @@ def _build_anthropic_http_client() -> httpx.AsyncClient:
)
# Placeholder used when no Anthropic key is configured; lets the engine boot so a
# deployment that only uses another provider (e.g. Ollama via the admin UI / config
# push) isn't blocked. Anthropic calls then fail with a clear 401 instead.
_UNCONFIGURED_ANTHROPIC_KEY = "unconfigured"
_warned_missing_anthropic_key = False
def _anthropic_provider(explicit_key: str | None = None) -> AnthropicProvider:
"""Build the Anthropic provider, tolerating a missing key at startup.
pydantic-ai's ``AnthropicProvider`` raises at construction when no key is
available, which would crash the whole engine at boot even for deployments
that never call Anthropic (Ollama/OpenAI-configured, or awaiting a config
push that supplies credentials). We fall back to a placeholder key and warn
once so startup succeeds; the placeholder only surfaces as a clear
provider-side auth error if an Anthropic call is actually made.
"""
http_client = _build_anthropic_http_client()
key = explicit_key or os.environ.get("ANTHROPIC_API_KEY")
if key:
return AnthropicProvider(api_key=key, http_client=http_client)
global _warned_missing_anthropic_key
if not _warned_missing_anthropic_key:
_warned_missing_anthropic_key = True
logger.warning(
"ANTHROPIC_API_KEY is not set - the engine will start, but Anthropic model "
"calls will fail until a key is provided or a different provider is configured "
"(admin AI settings / config push)."
)
return AnthropicProvider(api_key=_UNCONFIGURED_ANTHROPIC_KEY, http_client=http_client)
class _NullContentCoercingTransport(httpx.AsyncHTTPTransport):
"""Coerce assistant ``content: null`` to ``""`` in outgoing OpenAI chat requests.
OpenAI's schema uses ``content: null`` for assistant messages that carry only
tool calls, but Ollama's OpenAI-compatible endpoint rejects it ("invalid message
content type: <nil>"), which intermittently breaks multi-turn tool conversations
(RAG search + tool-delivered output). Rewriting null -> "" makes them acceptable
without changing semantics. Scoped to the ollama/custom clients only.
"""
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
if request.headers.get("content-type", "").startswith("application/json") and request.content:
try:
body = json.loads(request.content)
except ValueError:
return await super().handle_async_request(request)
messages = body.get("messages")
if isinstance(messages, list):
changed = False
for message in messages:
if isinstance(message, dict) and message.get("content", "") is None:
message["content"] = ""
changed = True
if changed:
new_body = json.dumps(body).encode("utf-8")
headers = [(k, v) for k, v in request.headers.raw if k.lower() != b"content-length"]
request = httpx.Request(
method=request.method,
url=request.url,
headers=headers,
content=new_body,
extensions=request.extensions,
)
return await super().handle_async_request(request)
def _openai_compat_http_client() -> httpx.AsyncClient:
"""httpx client for Ollama/custom OpenAI-compatible endpoints (null-content fix)."""
return httpx.AsyncClient(transport=_NullContentCoercingTransport())
class ConcurrencyLimitedModel(WrapperModel):
"""Caps in-flight model API calls with a semaphore shared across the process."""
@@ -131,45 +208,97 @@ def _build_document_store(settings: AppSettings) -> DocumentStore:
assert_never(settings.documents_backend)
def _build_documents(settings: AppSettings) -> DocumentService:
"""Build the document service used by per-request RAG capabilities."""
logger.info("Documents: embedding_model=%s", settings.rag_embedding_model)
embedder = EmbeddingService(
model_name=settings.rag_embedding_model,
chunk_size=settings.rag_chunk_size,
chunk_overlap=settings.rag_chunk_overlap,
)
def _build_documents(settings: AppSettings, embedder: EmbeddingService | None = None) -> DocumentService:
"""Build the document service used by per-request RAG capabilities.
``embedder`` lets a cache-restore boot inject an already-built embedder (e.g.
an OpenAI-compatible ollama/custom one whose base_url the plain
``settings.rag_embedding_model`` string can't encode); None builds from settings.
"""
if embedder is None:
logger.info("Documents: embedding_model=%s", settings.rag_embedding_model)
embedder = EmbeddingService(
model_name=settings.rag_embedding_model,
chunk_size=settings.rag_chunk_size,
chunk_overlap=settings.rag_chunk_overlap,
)
store = _build_document_store(settings)
return DocumentService(embedder=embedder, store=store, default_top_k=settings.rag_default_top_k)
def build_runtime(settings: AppSettings) -> AppRuntime:
fast_model = _build_model(settings.fast_model_name)
smart_model = _build_model(settings.smart_model_name)
validate_structured_output_support(fast_model, settings.fast_model_name)
validate_structured_output_support(smart_model, settings.smart_model_name)
def build_runtime(
settings: AppSettings,
*,
documents: DocumentService | None = None,
fast_model: Model | None = None,
smart_model: Model | None = None,
embedder: EmbeddingService | None = None,
) -> AppRuntime:
"""Assemble the shared runtime.
``documents``/``fast_model``/``smart_model`` let a config-push rebuild reuse
the live vector-store connection pool and inject already-built (and already
validated) models instead of reconstructing them from the model-name strings.
``embedder`` injects a pre-built embedder when building a fresh document store
(cache-restore boot); it is ignored when ``documents`` is supplied. All None
(the lifespan path) preserves the original startup behaviour.
"""
fast = fast_model if fast_model is not None else _build_model(settings.fast_model_name)
smart = smart_model if smart_model is not None else _build_model(settings.smart_model_name)
validate_structured_output_support(fast, settings.fast_model_name)
validate_structured_output_support(smart, settings.smart_model_name)
# One semaphore across both tiers: the cap protects the provider account
# and process resources, which the tiers share.
model_semaphore = asyncio.Semaphore(settings.model_max_concurrency)
return AppRuntime(
settings=settings,
fast_model=ConcurrencyLimitedModel(fast_model, model_semaphore),
smart_model=ConcurrencyLimitedModel(smart_model, model_semaphore),
documents=_build_documents(settings),
fast_model=ConcurrencyLimitedModel(fast, model_semaphore),
smart_model=ConcurrencyLimitedModel(smart, model_semaphore),
documents=documents if documents is not None else _build_documents(settings, embedder),
)
def _build_model(model_name: str) -> Model:
"""Construct a model, injecting our keepalive-free httpx client for
Anthropic models so workers don't pick up stale pooled connections.
def _build_model(
model_name: str,
*,
provider: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
) -> Model:
"""Construct a model for ``model_name``.
Other providers fall back to ``infer_model`` defaults; the stale-pool
issue is specific to the Cloudflare-fronted Anthropic API in our
observations and the fix doesn't necessarily apply elsewhere.
With no explicit ``provider``/``api_key``/``base_url`` this keeps the original
env-driven behaviour: Anthropic-prefixed names get our keepalive-free httpx
client (so workers don't pick up stale pooled connections) and everything
else falls back to ``infer_model``.
When the caller passes an explicit provider (config-push path), ``model_name``
is the bare model without a ``provider:`` prefix and credentials come from the
call. An empty ``api_key``/``base_url`` is treated as unset so the provider
reads its native environment variable (e.g. ANTHROPIC_API_KEY / OPENAI_API_KEY).
"""
if model_name.startswith("anthropic:"):
bare_name = model_name.removeprefix("anthropic:")
provider = AnthropicProvider(http_client=_build_anthropic_http_client())
return AnthropicModel(bare_name, provider=provider)
return infer_model(model_name)
if not provider and not api_key and not base_url:
if model_name.startswith("anthropic:"):
bare_name = model_name.removeprefix("anthropic:")
return AnthropicModel(bare_name, provider=_anthropic_provider())
return infer_model(model_name)
provider_name = (provider or "").lower()
key = api_key or None
if provider_name == "anthropic":
return AnthropicModel(model_name, provider=_anthropic_provider(key))
if provider_name == "openai":
openai_provider = OpenAIProvider(api_key=key) if key else OpenAIProvider()
return OpenAIChatModel(model_name, provider=openai_provider)
if provider_name in ("ollama", "custom"):
# OpenAI-compatible endpoint. Ollama ignores the key but the SDK still
# requires a non-empty one, so default to a harmless placeholder. The custom
# http client coerces null assistant content so multi-turn tool calls work.
openai_provider = OpenAIProvider(
base_url=base_url or None,
api_key=key or "ollama",
http_client=_openai_compat_http_client(),
)
return OpenAIChatModel(model_name, provider=openai_provider)
raise ValueError(f"Unsupported model provider {provider!r}.")
+24
View File
@@ -0,0 +1,24 @@
{
"models": {
"provider": "ollama",
"smartModel": "smart-model-x",
"fastModel": "fast-model-x",
"smartMaxTokens": 1111,
"fastMaxTokens": 2222,
"apiKey": "provider-key-abc",
"baseUrl": "http://engine.example/v1"
},
"rag": {
"embeddingProvider": "custom",
"embeddingModel": "embed-model-x",
"embeddingApiKey": "embed-key-abc",
"embeddingBaseUrl": "http://embed.example/v1",
"topK": 33,
"maxSearches": 7
},
"limits": {
"maxPages": 111,
"maxCharacters": 222222,
"modelMaxConcurrency": 9
}
}
+73
View File
@@ -0,0 +1,73 @@
"""Tests for the encrypted config cache (stirling.config.config_cache)."""
from __future__ import annotations
from pathlib import Path
import pytest
from stirling.config import config_cache
from stirling.contracts import ConfigPushRequest
def _sample() -> ConfigPushRequest:
return ConfigPushRequest.model_validate(
{
"models": {
"provider": "anthropic",
"smartModel": "claude-haiku-4-5",
"fastModel": "claude-haiku-4-5",
"smartMaxTokens": 8192,
"fastMaxTokens": 2048,
"apiKey": "secret-key-value",
"baseUrl": "",
},
"rag": {
"embeddingProvider": "voyageai",
"embeddingModel": "voyage-4",
"embeddingApiKey": "embed-secret",
"embeddingBaseUrl": "",
"topK": 20,
"maxSearches": 5,
},
"limits": {"maxPages": 200, "maxCharacters": 200000, "modelMaxConcurrency": 32},
}
)
def test_roundtrip_with_shared_secret(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "the-shared-secret")
req = _sample()
config_cache.save_config(req, data_dir=tmp_path)
# HKDF-from-secret path: no keyfile is written.
assert not (tmp_path / "ai_config_cache.key").exists()
assert config_cache.load_config(data_dir=tmp_path) == req
# Secrets are encrypted at rest.
assert b"secret-key-value" not in (tmp_path / "ai_config_cache.enc").read_bytes()
def test_roundtrip_with_keyfile_when_no_secret(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "")
req = _sample()
config_cache.save_config(req, data_dir=tmp_path)
# No shared secret: a random keyfile is generated and reused for decrypt.
assert (tmp_path / "ai_config_cache.key").exists()
assert config_cache.load_config(data_dir=tmp_path) == req
def test_load_missing_returns_none(tmp_path: Path) -> None:
assert config_cache.load_config(data_dir=tmp_path) is None
def test_load_corrupt_returns_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "a-secret")
(tmp_path / "ai_config_cache.enc").write_bytes(b"not-a-valid-fernet-token")
assert config_cache.load_config(data_dir=tmp_path) is None
def test_wrong_key_returns_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "secret-a")
config_cache.save_config(_sample(), data_dir=tmp_path)
# A different secret derives a different key -> decrypt fails, returns None.
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "secret-b")
assert config_cache.load_config(data_dir=tmp_path) is None
+80
View File
@@ -0,0 +1,80 @@
"""Wire-contract test for the processor -> engine config push.
The Java processor (AiEngineConfigSync.buildConfigNode) serialises the admin AI settings as
camelCase JSON and POSTs them to /api/v1/config. Because ConfigPushRequest uses extra="ignore"
(so version skew never 422s), a field the engine can no longer map is silently dropped to its
default instead of erroring. This test pins the exact camelCase contract: every field in the
shared fixture must round-trip onto the model, so a rename on either side fails loudly.
The same fixture (tests/fixtures/processor_config_push.json) is the contract the Java
AiEngineConfigSyncTest asserts its serialiser produces - keep the two in sync.
"""
from __future__ import annotations
import json
from pathlib import Path
from stirling.contracts import ConfigPushRequest
FIXTURE = Path(__file__).parent / "fixtures" / "processor_config_push.json"
def _load() -> dict:
return json.loads(FIXTURE.read_text(encoding="utf-8"))
def test_processor_contract_round_trips_every_field() -> None:
payload = _load()
req = ConfigPushRequest.model_validate(payload)
m = payload["models"]
assert req.models.provider == m["provider"]
assert req.models.smart_model == m["smartModel"]
assert req.models.fast_model == m["fastModel"]
assert req.models.smart_max_tokens == m["smartMaxTokens"]
assert req.models.fast_max_tokens == m["fastMaxTokens"]
assert req.models.api_key == m["apiKey"]
assert req.models.base_url == m["baseUrl"]
r = payload["rag"]
assert req.rag.embedding_provider == r["embeddingProvider"]
assert req.rag.embedding_model == r["embeddingModel"]
assert req.rag.embedding_api_key == r["embeddingApiKey"]
assert req.rag.embedding_base_url == r["embeddingBaseUrl"]
assert req.rag.top_k == r["topK"]
assert req.rag.max_searches == r["maxSearches"]
limits = payload["limits"]
assert req.limits.max_pages == limits["maxPages"]
assert req.limits.max_characters == limits["maxCharacters"]
assert req.limits.model_max_concurrency == limits["modelMaxConcurrency"]
def test_processor_contract_has_no_unmapped_keys() -> None:
"""Guard the fixture itself: every wire key must map to a model field (none silently
absorbed by extra="ignore"). If the processor adds a field, extend the contract here."""
payload = _load()
expected = {
"models": {
"provider",
"smartModel",
"fastModel",
"smartMaxTokens",
"fastMaxTokens",
"apiKey",
"baseUrl",
},
"rag": {
"embeddingProvider",
"embeddingModel",
"embeddingApiKey",
"embeddingBaseUrl",
"topK",
"maxSearches",
},
"limits": {"maxPages", "maxCharacters", "modelMaxConcurrency"},
}
assert set(payload) == set(expected)
for section, keys in expected.items():
assert set(payload[section]) == keys
+249
View File
@@ -0,0 +1,249 @@
"""Tests for the config-push endpoint (POST /api/v1/config).
These drive the real lifespan (via ``with TestClient(app)``) so ``app.state`` is
populated, then exercise the gate + model-swap behaviour against the in-memory
"test" runtime built by :func:`build_app_settings`.
"""
from __future__ import annotations
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import patch
import pytest
from conftest import build_app_settings
from fastapi.testclient import TestClient
from stirling.api import app
from stirling.config import AppSettings, config_cache, load_settings
from stirling.contracts import ConfigPushRequest
@pytest.fixture(autouse=True)
def _isolate_config_cache(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Point the encrypted config cache at a per-test tmp dir so persistence never
touches the real engine/data dir or leaks between tests."""
monkeypatch.setattr(config_cache, "_default_data_dir", lambda: tmp_path)
@contextmanager
def _client(
settings_factory: Callable[[], AppSettings],
*,
client_addr: tuple[str, int] = ("127.0.0.1", 12345),
) -> Iterator[TestClient]:
"""Enter a TestClient whose lifespan builds app.state from ``settings_factory``.
Defaults to a loopback client address so the config endpoint's local-caller gate
(which blocks remote unauthenticated pushes) allows the request through.
"""
previous = app.dependency_overrides.get(load_settings)
app.dependency_overrides[load_settings] = settings_factory
try:
with TestClient(app, client=client_addr) as client:
yield client
finally:
if previous is None:
app.dependency_overrides.pop(load_settings, None)
else:
app.dependency_overrides[load_settings] = previous
def _anthropic_push() -> dict[str, object]:
return {
"models": {
"provider": "anthropic",
"smartModel": "claude-haiku-4-5",
"fastModel": "claude-haiku-4-5",
"smartMaxTokens": 4096,
"fastMaxTokens": 1024,
"apiKey": "test-key-not-a-real-secret",
"baseUrl": "",
},
"rag": {
"embeddingProvider": "",
"embeddingModel": "",
"embeddingApiKey": "",
"embeddingBaseUrl": "",
"topK": 7,
"maxSearches": 3,
},
"limits": {"maxPages": 50, "maxCharacters": 12345, "modelMaxConcurrency": 8},
}
def test_config_push_forbidden_when_disabled() -> None:
def factory() -> AppSettings:
return build_app_settings().model_copy(update={"allow_config_push": False})
with _client(factory) as client:
response = client.post("/api/v1/config", json=_anthropic_push())
assert response.status_code == 403
def test_config_push_from_non_local_caller_without_secret_returns_403() -> None:
"""Secure-by-default: with no shared secret, a remote caller cannot push a config."""
with _client(build_app_settings, client_addr=("203.0.113.9", 4444)) as client:
response = client.post("/api/v1/config", json=_anthropic_push())
assert response.status_code == 403
assert "STIRLING_ENGINE_SHARED_SECRET" in response.json()["detail"]
def test_config_push_applies_model_and_limits() -> None:
with _client(build_app_settings) as client:
response = client.post("/api/v1/config", json=_anthropic_push())
assert response.status_code == 200
body = response.json()
# Wire summary is camelCase and never echoes the api key.
assert body["smartModel"] == "claude-haiku-4-5"
assert body["fastModel"] == "claude-haiku-4-5"
assert body["smartMaxTokens"] == 4096
assert body["ragTopK"] == 7
assert body["maxPages"] == 50
assert body["modelMaxConcurrency"] == 8
assert "test-key-not-a-real-secret" not in response.text
# State was swapped: the running settings now reflect the push.
assert app.state.settings.smart_model_name == "claude-haiku-4-5"
assert app.state.settings.max_pages == 50
assert app.state.runtime.documents.default_top_k == 7
def test_config_push_unsupported_provider_returns_400_without_swap() -> None:
with _client(build_app_settings) as client:
before = app.state.runtime
payload = _anthropic_push()
payload["models"]["provider"] = "nonsense-provider" # type: ignore[index]
response = client.post("/api/v1/config", json=payload)
assert response.status_code == 400
# Running runtime is untouched when the push is rejected.
assert app.state.runtime is before
assert app.state.settings.smart_model_name == "test"
def test_config_push_unsupported_model_returns_400() -> None:
"""A model that fails structured-output validation is rejected with 400."""
with _client(build_app_settings) as client:
before = app.state.runtime
with patch(
"stirling.api.routes.config.validate_structured_output_support",
side_effect=ValueError("Unsupported model foo. This model does not support structured outputs."),
):
response = client.post("/api/v1/config", json=_anthropic_push())
assert response.status_code == 400
assert "does not support structured outputs" in response.json()["detail"]
assert app.state.runtime is before
def test_config_push_ollama_embedding_rebuilds_embedder() -> None:
"""A pushed ollama/custom embedding provider builds an OpenAI-compatible
embedder and swaps it onto the reused DocumentService without tearing down
the store; the response carries a re-index note."""
with _client(build_app_settings) as client:
store_before = app.state.runtime.documents
embedder_before = app.state.runtime.documents.embedder
payload = _anthropic_push()
payload["rag"] = {
"embeddingProvider": "ollama",
"embeddingModel": "nomic-embed-text",
"embeddingApiKey": "",
"embeddingBaseUrl": "http://localhost:11434/v1",
"topK": 9,
"maxSearches": 4,
}
response = client.post("/api/v1/config", json=payload)
assert response.status_code == 200
body = response.json()
assert body["ragEmbeddingModel"] == "ollama:nomic-embed-text"
assert any("re-index" in note for note in body["notes"])
docs = app.state.runtime.documents
# Same store object reused (connection pool intact), embedder swapped.
assert docs is store_before
assert docs.embedder is not embedder_before
assert docs.default_top_k == 9
def test_config_push_unsupported_embedding_provider_returns_400() -> None:
with _client(build_app_settings) as client:
embedder_before = app.state.runtime.documents.embedder
payload = _anthropic_push()
payload["rag"] = {
"embeddingProvider": "totally-bogus",
"embeddingModel": "x",
"embeddingApiKey": "",
"embeddingBaseUrl": "",
"topK": None,
"maxSearches": None,
}
response = client.post("/api/v1/config", json=payload)
assert response.status_code == 400
# Embedder untouched when the push is rejected.
assert app.state.runtime.documents.embedder is embedder_before
def test_config_push_empty_models_keep_env_value() -> None:
"""An empty models block keeps the engine's current (env) models but still
applies pushed limits."""
with _client(build_app_settings) as client:
payload = _anthropic_push()
payload["models"] = {
"provider": "",
"smartModel": "",
"fastModel": "",
"smartMaxTokens": None,
"fastMaxTokens": None,
"apiKey": "",
"baseUrl": "",
}
response = client.post("/api/v1/config", json=payload)
assert response.status_code == 200
# Env "test" model preserved for both tiers; limits still applied.
assert app.state.settings.smart_model_name == "test"
assert app.state.settings.fast_model_name == "test"
assert app.state.settings.max_pages == 50
def test_config_push_ignores_unknown_fields() -> None:
"""A newer processor pushing fields this engine doesn't know must not 422;
unknown fields are ignored and the rest of the push still applies."""
with _client(build_app_settings) as client:
payload = _anthropic_push()
payload["futureTopLevelField"] = {"anything": 1}
payload["models"]["experimentalFlag"] = True # type: ignore[index]
response = client.post("/api/v1/config", json=payload)
assert response.status_code == 200
assert app.state.settings.smart_model_name == "claude-haiku-4-5"
assert app.state.settings.max_pages == 50
def test_boot_restores_cached_config() -> None:
"""A persisted config is decrypted and applied on boot, overriding env."""
config_cache.save_config(ConfigPushRequest.model_validate(_anthropic_push()))
with _client(build_app_settings):
# Env model is "test"; the cache pushed claude-haiku-4-5 + limits.
assert app.state.settings.smart_model_name == "claude-haiku-4-5"
assert app.state.settings.fast_model_name == "claude-haiku-4-5"
assert app.state.settings.max_pages == 50
assert app.state.settings.smart_model_max_tokens == 4096
assert app.state.runtime.documents.default_top_k == 7
def test_boot_ignores_cache_when_push_disabled() -> None:
"""With allow_config_push false, env wins and the cache is ignored."""
config_cache.save_config(ConfigPushRequest.model_validate(_anthropic_push()))
def factory() -> AppSettings:
return build_app_settings().model_copy(update={"allow_config_push": False})
with _client(factory):
assert app.state.settings.smart_model_name == "test"
assert app.state.settings.max_pages == 200
def test_boot_proceeds_on_corrupt_cache(tmp_path: Path) -> None:
"""A corrupt cache file is ignored and boot falls back to env, never crashing."""
(tmp_path / "ai_config_cache.enc").write_bytes(b"not-a-valid-fernet-token")
with _client(build_app_settings):
assert app.state.settings.smart_model_name == "test"
assert app.state.settings.max_pages == 200
+2
View File
@@ -603,6 +603,7 @@ name = "engine"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "jinja2" },
{ name = "opentelemetry-sdk" },
@@ -630,6 +631,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "cryptography", specifier = ">=44.0.0" },
{ name = "fastapi", specifier = ">=0.116.0" },
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "opentelemetry-sdk", specifier = ">=1.39.0" },
@@ -692,6 +692,160 @@ manualLinks = "Manual downloads: click the links and place the files into the te
noLanguages = "No tessdata languages found in the configured directory."
permissionNotice = "The tessdata path is not writable. Downloads will be opened in the browser; please save the .traineddata files manually into the tessdata folder."
# AI engine admin settings (AI nav group)
[admin.settings.ai.documents]
description = "Configure the embedding model and retrieval settings used to answer questions over documents. Applied to the AI engine when saved."
title = "Documents & RAG"
[admin.settings.ai.documents.embeddingApiKey]
description = "Leave blank to use the engine's own environment credential. Applies to self-hosted single-engine deployments."
generic = "Embedding API key"
openai = "OpenAI API key"
voyageai = "VoyageAI API key"
[admin.settings.ai.documents.embeddingBaseUrl]
description = "Base URL of the OpenAI-compatible / Ollama embeddings endpoint, e.g. http://ollama:11434/v1. Must point at a trusted internal endpoint (SSRF-sensitive)."
label = "Embedding base URL"
[admin.settings.ai.documents.embeddingModel]
description = "Embedding model name. Free text; suggestions are hints only."
label = "Embedding model"
[admin.settings.ai.documents.embeddingProvider]
description = "Provider used to turn document text into vector embeddings."
label = "Embedding provider"
[admin.settings.ai.documents.maxSearches]
description = "Maximum number of retrieval searches the agent may run per request."
label = "Max searches"
[admin.settings.ai.documents.reindexNote]
body = "Changing the embedding model invalidates previously-embedded documents. A full engine restart and re-index of existing documents is required for search to work correctly."
title = "Re-index required"
[admin.settings.ai.documents.topK]
description = "Number of most-relevant chunks retrieved per search."
label = "Top K"
[admin.settings.ai.general]
description = "Connect Stirling to the Python AI engine and choose which AI capabilities are exposed. Changes apply on restart."
title = "AI Engine"
[admin.settings.ai.general.capabilities]
description = "Turn individual AI features on or off. Disabled features are hidden in the app."
title = "Capabilities"
[admin.settings.ai.general.enabled]
description = "Master switch. When off, no AI tools, agents, or engine calls are available."
label = "Enable AI"
[admin.settings.ai.general.features.chat]
description = "Conversational assistant for working with PDFs."
label = "Chat assistant"
[admin.settings.ai.general.features.classify]
description = "Automatically categorise documents by type or content."
label = "Document classification"
[admin.settings.ai.general.features.createPdf]
description = "Generate a new PDF (e.g. from HTML) via an AI agent."
label = "Create PDF from prompt"
[admin.settings.ai.general.features.documentQuestions]
description = "Ask questions and get answers grounded in an uploaded document."
label = "Document questions"
[admin.settings.ai.general.features.mathAuditor]
description = "Review documents for mathematical and numerical errors."
label = "Math auditor"
[admin.settings.ai.general.features.pdfComment]
description = "Add AI-authored review comments and annotations to a PDF."
label = "PDF comment agent"
[admin.settings.ai.general.longRunningTimeoutSeconds]
description = "Timeout for heavier agent operations such as document generation."
label = "Long-running timeout (seconds)"
[admin.settings.ai.general.note]
body = "The AI engine runs as a separate service. Its shared secret"
body2 = "is set via a container environment variable. Provider API keys can be entered on these pages or supplied as engine environment variables; keys entered here are pushed to the engine when saved."
title = "About the AI engine"
[admin.settings.ai.general.streamTimeoutSeconds]
description = "Timeout for streamed (token-by-token) chat responses."
label = "Stream timeout (seconds)"
[admin.settings.ai.general.test]
button = "Test connection"
failBody = "The AI engine did not respond. Check the URL, that the engine container is running, and that AI is enabled (a restart is needed after enabling)."
failTitle = "AI engine unreachable"
okBody = "The AI engine responded to a health check."
okTitle = "AI engine reachable"
[admin.settings.ai.general.timeoutSeconds]
description = "Timeout for standard AI requests to the engine."
label = "Request timeout (seconds)"
[admin.settings.ai.general.url]
description = "Internal URL of the Python AI engine, e.g. http://stirling-pdf-engine:5001."
label = "AI engine URL"
[admin.settings.ai.limits]
description = "Guardrails for how much work AI requests may do and how many run concurrently. Applied to the AI engine when saved."
title = "Limits & Performance"
[admin.settings.ai.limits.maxCharacters]
description = "Guardrail: reject AI requests whose extracted text exceeds this length."
label = "Max characters per request"
[admin.settings.ai.limits.maxPages]
description = "Guardrail: reject AI requests over this many PDF pages."
label = "Max pages per request"
[admin.settings.ai.limits.modelMaxConcurrency]
description = "Maximum simultaneous in-flight model calls across the whole engine."
label = "Model max concurrency"
[admin.settings.ai.models]
description = "Choose the LLM provider and the smart/fast models the AI engine uses. Applied to the AI engine when saved."
title = "Models & Providers"
[admin.settings.ai.models.apiKey]
anthropic = "Anthropic API key"
description = "Leave blank to use the engine's own environment credential. Applies to self-hosted single-engine deployments."
generic = "API key"
openai = "OpenAI API key"
[admin.settings.ai.models.baseUrl]
description = "Base URL of the OpenAI-compatible / Ollama endpoint, e.g. http://ollama:11434/v1."
label = "Provider base URL"
warning = "The base URL must point at a trusted internal endpoint. The engine will make server-side requests to it, so an untrusted value is SSRF-sensitive."
[admin.settings.ai.models.fastMaxTokens]
description = "Maximum output tokens for the fast model."
label = "Fast model max tokens"
[admin.settings.ai.models.fastModel]
description = "Cheaper, faster model for lightweight tasks. Free text; suggestions are hints only."
label = "Fast model"
[admin.settings.ai.models.provider]
description = "Which LLM provider the engine talks to."
label = "Provider"
[admin.settings.ai.models.smartMaxTokens]
description = "Maximum output tokens for the smart model."
label = "Smart model max tokens"
[admin.settings.ai.models.smartModel]
description = "High-capability model for complex reasoning. Free text; suggestions are hints only."
label = "Smart model"
[admin.settings.ai.saved]
body = "Changes are pushed to the AI engine automatically."
title = "AI settings saved"
[admin.settings.badge]
clickToUpgrade = "Click to view plan details"
@@ -6951,31 +7105,31 @@ subtitle = "Deployments, credentials, security posture, storage, and the audit t
title = "Infrastructure"
# Fixed-enum label maps rendered via t(MAP[value]) in the infrastructure tabs.
[portal.infrastructure.apiKeyPermission]
admin = "Admin"
read = "Read"
write = "Write"
[portal.infrastructure.apiKeys]
createKey = "Create key"
heading = "API keys"
subheading = "Personal credentials, each with its own usage tracking."
subheading = "Scoped credentials with per-key rate limits, permissions, and IP allowlists."
[portal.infrastructure.apiKeys.card]
allowedIps = "Allowed IPs"
anyIp = "Any IP (no allowlist)"
created = "Created"
lastUsed = "Last used"
revoke = "Revoke key"
permissions = "Permissions"
rateLimit = "Rate limit"
rateLimitValue = "{{value}} req/min"
usageMonth = "Usage this month"
usageToday = "Usage today"
[portal.infrastructure.apiKeys.empty]
description = "Create a key to start calling the Stirling API."
description = "Create a scoped key to start calling the Stirling API."
title = "No API keys yet"
[portal.infrastructure.apiKeys.error]
load = "Couldn't load your API keys. Please try again."
[portal.infrastructure.apiKeys.revoke]
body = "Revoke \"{{name}}\"? Any integration still using this key will immediately start receiving 401 errors. This can't be undone."
cancel = "Cancel"
confirm = "Revoke key"
title = "Revoke API key"
[portal.infrastructure.attestationLabel]
attested = "Attested"
inScope = "In scope"
@@ -7061,11 +7215,14 @@ notStarted = "Not started"
cancel = "Cancel"
createKey = "Create key"
done = "Done"
ipAllowlistHelper = "Comma-separated CIDR ranges. Leave blank to allow any IP."
ipAllowlistLabel = "IP allowlist"
keyNameLabel = "Key name"
keyNamePlaceholder = "e.g. Production ingest"
keyNamePlaceholder = "e.g. Production · ingest"
permissionsLabel = "Permissions"
secretKeyCaption = "Secret key"
secretWarning = "Store this in a secrets manager. Stirling only ever stores a hash, so there is no way to recover it later."
subtitle = "Give the key a name so you can recognise it later. You can revoke it at any time."
secretWarning = "Store this in a secrets manager. Stirling only ever stores a hash — there is no way to recover it later."
subtitle = "Scope the key to the minimum it needs. You can rotate or revoke at any time."
subtitleCreated = "Copy this secret now — it won't be shown again."
title = "Create API key"
titleCreated = "Key created"
@@ -7114,6 +7271,7 @@ title = "No regions deployed"
[portal.infrastructure.keyLabel]
active = "Active"
revoked = "Revoked"
rotateSoon = "Rotate soon"
[portal.infrastructure.modelLabel]
active = "Active"
@@ -8018,7 +8176,6 @@ title = "Sources"
[portal.sources.actions]
agentBuilder = "Agent Builder"
connectSource = "Connect source"
createApiKey = "Create API key"
[portal.sources.builder]
back = "Back to sources"
@@ -8949,6 +9106,13 @@ title = "Signature Order"
[settings]
close = "Close"
[settings.ai]
documents = "Documents & RAG"
general = "General"
limits = "Limits & Performance"
models = "Models & Providers"
title = "AI"
[settings.configuration]
advanced = "Advanced"
database = "Database"
@@ -32,6 +32,10 @@ export const VALID_NAV_KEYS = [
"adminEndpoints",
"adminStorageSharing",
"adminMcp",
"adminAiGeneral",
"adminAiModels",
"adminAiDocuments",
"adminAiLimits",
"help",
"legal",
"backendThirdPartyLicenses",
@@ -43,33 +43,23 @@ export interface RecentDeployment {
/* API Keys */
/* ──────────────────────────────────────────────────────────────────────── */
export type ApiKeyStatus = "active" | "revoked";
export type ApiKeyStatus = "active" | "revoked" | "rotate-soon";
export type ApiKeyPermission = "Read" | "Write" | "Admin";
export interface ApiKey {
id: string;
name: string;
/** Non-secret leading fragment, e.g. "sk_a3f81b2c". */
/** Masked prefix shown in the list, e.g. "sk_live_a3f8…". */
prefix: string;
created: string;
/** Formatted last-use time, or "Never". */
lastUsed: string;
status: ApiKeyStatus;
/** Requests made today (UTC). */
/** Requests/min ceiling. */
rateLimit: number;
permissions: ApiKeyPermission[];
allowedIps: string[];
usageToday: number;
/** Requests in the trailing 30 days. */
usageMonth: number;
/** Lifetime request count. */
usageTotal: number;
}
export interface ApiKeysResponse {
keys: ApiKey[];
}
/** Returned once on creation: the listed row plus the plaintext secret, shown once. */
export interface CreatedApiKey {
key: ApiKey;
secret: string;
}
/* ──────────────────────────────────────────────────────────────────────── */
@@ -288,34 +278,11 @@ export async function fetchDeployments(
);
}
const API_KEYS_PATH = "/api/v1/proprietary/ui-data/infrastructure/api-keys";
/** GET the caller's personal API keys; SaaS or local, scoped server-side per user. */
export async function fetchApiKeys(): Promise<ApiKeysResponse> {
return apiClient.saas.isConfigured()
? apiClient.saas.json<ApiKeysResponse>(API_KEYS_PATH)
: apiClient.local.json<ApiKeysResponse>(API_KEYS_PATH);
}
/** POST a new key; the response carries the one-time secret. */
export async function createApiKey(body: {
name: string;
}): Promise<CreatedApiKey> {
const opts = { method: "POST" as const, body };
return apiClient.saas.isConfigured()
? apiClient.saas.json<CreatedApiKey>(API_KEYS_PATH, opts)
: apiClient.local.json<CreatedApiKey>(API_KEYS_PATH, opts);
}
/** DELETE (revoke) a key the caller owns. */
export async function revokeApiKey(id: string): Promise<void> {
const path = `${API_KEYS_PATH}/${encodeURIComponent(id)}`;
const opts = { method: "DELETE" as const };
if (apiClient.saas.isConfigured()) {
await apiClient.saas.json<void>(path, opts);
} else {
await apiClient.local.json<void>(path, opts);
}
/** GET /v1/infrastructure/api-keys?tier=… */
export async function fetchApiKeys(tier: Tier): Promise<ApiKey[]> {
return apiClient.local.json<ApiKey[]>(
`/v1/infrastructure/api-keys${q(tier)}`,
);
}
/** GET /v1/infrastructure/security?tier=… */
@@ -6,20 +6,21 @@ import "@portal/views/Infrastructure.css";
const BASE: ApiKey = {
id: "key-1",
name: "Production · ingest",
prefix: "sk_a3f81b2c",
created: "2026-03-02",
lastUsed: "2026-07-10 09:14",
prefix: "sk_live_a3f8…",
created: "Mar 2, 2026",
lastUsed: "2m ago",
status: "active",
rateLimit: 1200,
permissions: ["Read", "Write"],
allowedIps: ["52.14.0.0/16", "18.221.0.0/16"],
usageToday: 84210,
usageMonth: 2410933,
usageTotal: 9820145,
};
const meta: Meta<typeof ApiKeyCard> = {
title: "Portal/Infrastructure/ApiKeyCard",
component: ApiKeyCard,
parameters: { layout: "padded" },
args: { onRevoke: (key: ApiKey) => console.log("revoke", key.id) },
decorators: [
(S) => (
<div style={{ maxWidth: "44rem" }}>
@@ -31,18 +32,37 @@ const meta: Meta<typeof ApiKeyCard> = {
export default meta;
type Story = StoryObj<typeof ApiKeyCard>;
export const Personal: Story = { args: { apiKey: BASE } };
export const Active: Story = { args: { apiKey: BASE } };
export const RotateSoon: Story = {
args: {
apiKey: {
...BASE,
name: "Ops · admin (legacy)",
status: "rotate-soon",
permissions: ["Read", "Write", "Admin"],
allowedIps: ["203.0.113.7/32"],
usageToday: 0,
},
},
};
export const Revoked: Story = {
args: {
apiKey: {
...BASE,
name: "Sandbox · webhook tester",
prefix: "sk_2c4a91de",
prefix: "sk_test_2c4a…",
status: "revoked",
lastUsed: "Never",
lastUsed: "never",
permissions: ["Read"],
allowedIps: [],
usageToday: 0,
usageMonth: 0,
},
},
};
export const NoIpAllowlist: Story = {
args: { apiKey: { ...BASE, allowedIps: [] } },
};
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Button, Card, StatusBadge } from "@app/ui";
import { Button, Card, Chip, StatusBadge } from "@app/ui";
import { useTranslation } from "react-i18next";
import type { ApiKey } from "@portal/api/infrastructure";
import {
@@ -8,17 +8,9 @@ import {
} from "@portal/components/infrastructure/infraFormat";
/** Collapsible row for a single API key: header summary + expandable detail grid. */
export function ApiKeyCard({
apiKey,
onRevoke,
}: {
apiKey: ApiKey;
/** Ask to revoke this key; the parent confirms before the destructive call. */
onRevoke: (key: ApiKey) => void;
}) {
export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const revocable = apiKey.status === "active";
return (
<Card padding="default" className="portal-infra__key">
<Button
@@ -59,6 +51,14 @@ export function ApiKeyCard({
<dt>{t("portal.infrastructure.apiKeys.card.lastUsed")}</dt>
<dd>{apiKey.lastUsed}</dd>
</div>
<div>
<dt>{t("portal.infrastructure.apiKeys.card.rateLimit")}</dt>
<dd className="portal-infra__mono">
{t("portal.infrastructure.apiKeys.card.rateLimitValue", {
value: apiKey.rateLimit.toLocaleString(),
})}
</dd>
</div>
<div>
<dt>{t("portal.infrastructure.apiKeys.card.usageToday")}</dt>
<dd className="portal-infra__mono">
@@ -71,20 +71,36 @@ export function ApiKeyCard({
{apiKey.usageMonth.toLocaleString()}
</dd>
</div>
</dl>
{revocable && (
<div className="portal-infra__modal-actions">
<Button
variant="secondary"
accent="danger"
size="sm"
onClick={() => onRevoke(apiKey)}
>
{t("portal.infrastructure.apiKeys.card.revoke")}
</Button>
<div>
<dt>{t("portal.infrastructure.apiKeys.card.permissions")}</dt>
<dd className="portal-infra__chips">
{apiKey.permissions.map((p) => (
<Chip key={p} size="sm">
{t(
`portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`,
p,
)}
</Chip>
))}
</dd>
</div>
)}
<div className="portal-infra__kv-wide">
<dt>{t("portal.infrastructure.apiKeys.card.allowedIps")}</dt>
<dd className="portal-infra__chips">
{apiKey.allowedIps.length === 0 ? (
<span className="portal-infra__muted">
{t("portal.infrastructure.apiKeys.card.anyIp")}
</span>
) : (
apiKey.allowedIps.map((ip) => (
<Chip key={ip} size="sm">
<span className="portal-infra__mono">{ip}</span>
</Chip>
))
)}
</dd>
</div>
</dl>
</div>
)}
</Card>
@@ -20,19 +20,14 @@ type Story = StoryObj<typeof ApiKeysTab>;
export const Default: Story = {};
const EMPTY = { keys: [] };
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get(
"*/api/v1/proprietary/ui-data/infrastructure/api-keys",
async () => {
await delay("infinite");
return HttpResponse.json(EMPTY);
},
),
http.get("/v1/infrastructure/api-keys", async () => {
await delay("infinite");
return HttpResponse.json([]);
}),
],
},
},
@@ -42,9 +37,7 @@ export const Empty: Story = {
parameters: {
msw: {
handlers: [
http.get("*/api/v1/proprietary/ui-data/infrastructure/api-keys", () =>
HttpResponse.json(EMPTY),
),
http.get("/v1/infrastructure/api-keys", () => HttpResponse.json([])),
],
},
},
@@ -1,48 +1,20 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Banner, Button, EmptyState, Modal, Skeleton } from "@app/ui";
import { useAsync } from "@portal/hooks/useAsync";
import {
fetchApiKeys,
revokeApiKey,
type ApiKey,
type ApiKeysResponse,
} from "@portal/api/infrastructure";
import { errorMessage } from "@portal/api/http";
import { Button, EmptyState, Skeleton } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { fetchApiKeys, type ApiKey } from "@portal/api/infrastructure";
import { ApiKeyCard } from "@portal/components/infrastructure/ApiKeyCard";
import { CreateKeyModal } from "@portal/components/infrastructure/CreateKeyModal";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
export function ApiKeysTab() {
const { t } = useTranslation();
const { tier } = useTier();
const [modalOpen, setModalOpen] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [error, setError] = useState<string | null>(null);
const [pendingRevoke, setPendingRevoke] = useState<ApiKey | null>(null);
const [revoking, setRevoking] = useState(false);
const state = useAsync<ApiKeysResponse>(() => fetchApiKeys(), [reloadKey]);
const { data, loading, error: loadError } = state;
const reload = () => setReloadKey((n) => n + 1);
const keys = data?.keys ?? [];
const isLoading = loading && data === null;
// A failed load must not masquerade as a genuinely empty list.
const isEmpty = !loading && !loadError && keys.length === 0;
async function confirmRevoke() {
if (!pendingRevoke) return;
setError(null);
setRevoking(true);
try {
await revokeApiKey(pendingRevoke.id);
setPendingRevoke(null);
reload();
} catch (e) {
setError(errorMessage(e));
} finally {
setRevoking(false);
}
}
const state = useAsync<ApiKey[]>(() => fetchApiKeys(tier), [tier]);
const { data: keys } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
return (
<div className="portal-infra__stack">
@@ -60,14 +32,6 @@ export function ApiKeysTab() {
</Button>
</div>
{error && <Banner tone="danger" description={error} />}
{!loading && loadError && (
<Banner
tone="danger"
description={t("portal.infrastructure.apiKeys.error.load")}
/>
)}
{isLoading && (
<div className="portal-infra__stack" aria-hidden>
{Array.from({ length: 3 }).map((_, i) => (
@@ -84,52 +48,15 @@ export function ApiKeysTab() {
/>
)}
{keys.length > 0 && (
{keys && keys.length > 0 && (
<div className="portal-infra__keys">
{keys.map((k) => (
<ApiKeyCard key={k.id} apiKey={k} onRevoke={setPendingRevoke} />
<ApiKeyCard key={k.id} apiKey={k} />
))}
</div>
)}
<CreateKeyModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onCreated={reload}
/>
<Modal
open={pendingRevoke !== null}
onClose={() => !revoking && setPendingRevoke(null)}
width="sm"
title={t("portal.infrastructure.apiKeys.revoke.title")}
footer={
<div className="portal-infra__modal-actions">
<Button
variant="tertiary"
size="sm"
disabled={revoking}
onClick={() => setPendingRevoke(null)}
>
{t("portal.infrastructure.apiKeys.revoke.cancel")}
</Button>
<Button
size="sm"
accent="danger"
loading={revoking}
onClick={confirmRevoke}
>
{t("portal.infrastructure.apiKeys.revoke.confirm")}
</Button>
</div>
}
>
<p>
{t("portal.infrastructure.apiKeys.revoke.body", {
name: pendingRevoke?.name ?? "",
})}
</p>
</Modal>
<CreateKeyModal open={modalOpen} onClose={() => setModalOpen(false)} />
</div>
);
}
@@ -6,11 +6,7 @@ const meta: Meta<typeof CreateKeyModal> = {
title: "Portal/Infrastructure/CreateKeyModal",
component: CreateKeyModal,
parameters: { layout: "fullscreen" },
args: {
open: true,
onClose: () => console.log("close"),
onCreated: () => console.log("created"),
},
args: { open: true, onClose: () => console.log("close") },
};
export default meta;
type Story = StoryObj<typeof CreateKeyModal>;
@@ -1,63 +0,0 @@
import type { ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
// Deterministic i18n: keys returned verbatim, so assertions are stable.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn() },
}),
}));
// Stub the API layer so no real request is made; capture the create payload.
// vi.hoisted keeps the mock fn defined before the hoisted vi.mock factory runs.
const { createApiKey } = vi.hoisted(() => ({ createApiKey: vi.fn() }));
vi.mock("@portal/api/infrastructure", () => ({ createApiKey }));
import { CreateKeyModal } from "@portal/components/infrastructure/CreateKeyModal";
const K = "portal.infrastructure.createKey";
function renderModal(props: Partial<ComponentProps<typeof CreateKeyModal>>) {
return render(
<MantineProvider>
<CreateKeyModal open onClose={() => {}} onCreated={() => {}} {...props} />
</MantineProvider>,
);
}
describe("CreateKeyModal", () => {
it("gates the create button on a non-empty name", () => {
renderModal({});
const cta = screen.getByRole("button", { name: `${K}.createKey` });
expect(cta).toBeDisabled();
fireEvent.change(screen.getByPlaceholderText(`${K}.keyNamePlaceholder`), {
target: { value: "Production ingest" },
});
expect(cta).toBeEnabled();
});
it("creates a key and reveals the returned secret", async () => {
createApiKey.mockResolvedValueOnce({
key: { id: "1", name: "Production ingest" },
secret: "sk_live_demo_key_rotate_in_prod",
});
const onCreated = vi.fn();
renderModal({ onCreated });
fireEvent.change(screen.getByPlaceholderText(`${K}.keyNamePlaceholder`), {
target: { value: "Production ingest" },
});
fireEvent.click(screen.getByRole("button", { name: `${K}.createKey` }));
expect(await screen.findByText(`${K}.secretWarning`)).toBeInTheDocument();
expect(
screen.getByText("sk_live_demo_key_rotate_in_prod"),
).toBeInTheDocument();
await waitFor(() => expect(onCreated).toHaveBeenCalled());
expect(createApiKey).toHaveBeenCalledWith({ name: "Production ingest" });
});
});
@@ -1,30 +1,40 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Banner, Button, CodeBlock, FormField, Input, Modal } from "@app/ui";
import { createApiKey, type CreatedApiKey } from "@portal/api/infrastructure";
import { errorMessage } from "@portal/api/http";
import {
Banner,
Button,
Checkbox,
CodeBlock,
FormField,
Input,
Modal,
} from "@app/ui";
import type { ApiKeyPermission } from "@portal/api/infrastructure";
const PERMISSION_OPTS: ApiKeyPermission[] = ["Read", "Write", "Admin"];
// Shown once after a key is created. TODO(backend): use the one-time secret
// returned by POST /v1/infrastructure/api-keys — it is never persisted server-side.
const DEMO_NEW_KEY_SECRET = "sk_live_demo_key_rotate_in_prod";
export function CreateKeyModal({
open,
onClose,
onCreated,
}: {
open: boolean;
onClose: () => void;
/** Called after a successful create so the tab can refresh its list. */
onCreated: () => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [created, setCreated] = useState<CreatedApiKey | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [perms, setPerms] = useState<ApiKeyPermission[]>(["Read"]);
const [ips, setIps] = useState("");
const [created, setCreated] = useState(false);
function reset() {
setName("");
setCreated(null);
setSubmitting(false);
setError(null);
setPerms(["Read"]);
setIps("");
setCreated(false);
}
function close() {
@@ -33,18 +43,16 @@ export function CreateKeyModal({
setTimeout(reset, 200);
}
async function createKey() {
setSubmitting(true);
setError(null);
try {
const result = await createApiKey({ name: name.trim() });
setCreated(result);
onCreated();
} catch (e) {
setError(errorMessage(e));
} finally {
setSubmitting(false);
}
function togglePerm(p: ApiKeyPermission) {
setPerms((prev) =>
prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p],
);
}
function createKey() {
// TODO(backend): POST /v1/infrastructure/api-keys { name, perms, ips }
// and render the one-time secret from the response instead of the fixture.
setCreated(true);
}
return (
@@ -64,7 +72,7 @@ export function CreateKeyModal({
}
footer={
created ? (
<Button variant="primary" onClick={close}>
<Button variant="primary" accent="premium" onClick={close}>
{t("portal.infrastructure.createKey.done")}
</Button>
) : (
@@ -74,7 +82,8 @@ export function CreateKeyModal({
</Button>
<Button
variant="primary"
disabled={name.trim() === "" || submitting}
accent="premium"
disabled={name.trim() === "" || perms.length === 0}
onClick={createKey}
>
{t("portal.infrastructure.createKey.createKey")}
@@ -86,7 +95,7 @@ export function CreateKeyModal({
{created ? (
<div className="portal-infra__stack">
<CodeBlock
code={created.secret}
code={DEMO_NEW_KEY_SECRET}
lang="bash"
caption={t("portal.infrastructure.createKey.secretKeyCaption")}
/>
@@ -97,8 +106,6 @@ export function CreateKeyModal({
</div>
) : (
<div className="portal-infra__form">
{error && <Banner tone="danger" description={error} />}
<FormField
label={t("portal.infrastructure.createKey.keyNameLabel")}
required
@@ -111,6 +118,35 @@ export function CreateKeyModal({
)}
/>
</FormField>
<FormField
label={t("portal.infrastructure.createKey.permissionsLabel")}
>
<div className="portal-infra__perm-row">
{PERMISSION_OPTS.map((p) => (
<Checkbox
key={p}
label={t(
`portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`,
p,
)}
checked={perms.includes(p)}
onChange={() => togglePerm(p)}
/>
))}
</div>
</FormField>
<FormField
label={t("portal.infrastructure.createKey.ipAllowlistLabel")}
helperText={t("portal.infrastructure.createKey.ipAllowlistHelper")}
>
<Input
value={ips}
onChange={(e) => setIps(e.target.value)}
placeholder="52.14.0.0/16, 203.0.113.7/32"
/>
</FormField>
</div>
)}
</Modal>
@@ -55,11 +55,13 @@ export const DEPLOY_LABEL: Record<DeploymentStatus, string> = {
export const KEY_TONE: Record<ApiKeyStatus, StatusTone> = {
active: "success",
revoked: "danger",
"rotate-soon": "warning",
};
export const KEY_LABEL: Record<ApiKeyStatus, string> = {
active: "portal.infrastructure.keyLabel.active",
revoked: "portal.infrastructure.keyLabel.revoked",
"rotate-soon": "portal.infrastructure.keyLabel.rotateSoon",
};
export const CERT_TONE: Record<CertStatus, StatusTone> = {
@@ -25,48 +25,10 @@ export const infrastructureHandlers = [
});
}),
// Real backend route; wildcard prefix intercepts both local (same-origin) and
// SaaS (absolute) callers.
http.get(
"*/api/v1/proprietary/ui-data/infrastructure/api-keys",
async ({ request }) => {
await delay(120);
return HttpResponse.json(apiKeysFor(tierFrom(request)));
},
),
// Create returns a one-time secret; the mock is non-persistent (dev/Storybook only).
http.post(
"*/api/v1/proprietary/ui-data/infrastructure/api-keys",
async ({ request }) => {
await delay(120);
const body = (await request.json().catch(() => ({}))) as {
name?: string;
};
return HttpResponse.json({
key: {
id: `key-${Date.now()}`,
name: body.name ?? "New key",
prefix: "sk_demo0000",
created: "2026-07-10",
lastUsed: "Never",
status: "active",
usageToday: 0,
usageMonth: 0,
usageTotal: 0,
},
secret: "sk_live_demo_key_rotate_in_prod",
});
},
),
http.delete(
"*/api/v1/proprietary/ui-data/infrastructure/api-keys/:id",
async () => {
await delay(120);
return new HttpResponse(null, { status: 204 });
},
),
http.get("/v1/infrastructure/api-keys", async ({ request }) => {
await delay(120);
return HttpResponse.json(apiKeysFor(tierFrom(request)));
}),
http.get("/v1/infrastructure/security", async ({ request }) => {
await delay(120);
@@ -15,7 +15,6 @@
import type { Tier } from "@portal/contexts/TierContext";
import type {
ApiKey,
ApiKeysResponse,
AuditEvent,
AuditLogResponse,
AuditSummary,
@@ -167,57 +166,61 @@ const API_KEYS_ALL: ApiKey[] = [
{
id: "key-1",
name: "Production · ingest",
prefix: "sk_a3f81b2c",
created: "2026-03-02",
lastUsed: "2026-07-10 09:14",
prefix: "sk_live_a3f8…",
created: "Mar 2, 2026",
lastUsed: "2m ago",
status: "active",
rateLimit: 1200,
permissions: ["Read", "Write"],
allowedIps: ["52.14.0.0/16", "18.221.0.0/16"],
usageToday: 84210,
usageMonth: 2410933,
usageTotal: 2410933,
},
{
id: "key-2",
name: "Nightly batch",
prefix: "sk_77be0f42",
created: "2026-01-18",
lastUsed: "2026-07-10 08:33",
name: "Analytics · read-only",
prefix: "sk_live_77be…",
created: "Jan 18, 2026",
lastUsed: "41m ago",
status: "active",
rateLimit: 300,
permissions: ["Read"],
allowedIps: [],
usageToday: 6120,
usageMonth: 188400,
usageTotal: 188400,
},
{
id: "key-3",
name: "CI pipeline",
prefix: "sk_d9013ab7",
created: "2025-08-09",
lastUsed: "2026-07-04 17:02",
status: "active",
name: "Ops · admin (legacy)",
prefix: "sk_live_d901…",
created: "Aug 9, 2025",
lastUsed: "6d ago",
status: "rotate-soon",
rateLimit: 600,
permissions: ["Read", "Write", "Admin"],
allowedIps: ["203.0.113.7/32"],
usageToday: 0,
usageMonth: 14200,
usageTotal: 14200,
},
{
id: "key-4",
name: "Sandbox · webhook tester",
prefix: "sk_2c4a91de",
created: "2026-05-30",
lastUsed: "Never",
prefix: "sk_test_2c4a…",
created: "May 30, 2026",
lastUsed: "never",
status: "revoked",
rateLimit: 60,
permissions: ["Read"],
allowedIps: [],
usageToday: 0,
usageMonth: 0,
usageTotal: 0,
},
];
export function apiKeysFor(tier: Tier): ApiKeysResponse {
const keys =
tier === "free"
? API_KEYS_ALL.slice(0, 1)
: tier === "pro"
? API_KEYS_ALL.slice(0, 3)
: API_KEYS_ALL;
return { keys };
export function apiKeysFor(tier: Tier): ApiKey[] {
if (tier === "free") return API_KEYS_ALL.slice(0, 1);
if (tier === "pro") return API_KEYS_ALL.slice(0, 3);
return API_KEYS_ALL;
}
/* ──────────────────────────────────────────────────────────────────────── */
@@ -17,6 +17,10 @@ import AdminPlanSection from "@app/components/shared/config/configSections/Admin
import AdminFeaturesSection from "@app/components/shared/config/configSections/AdminFeaturesSection";
import AdminEndpointsSection from "@app/components/shared/config/configSections/AdminEndpointsSection";
import AdminMcpSection from "@app/components/shared/config/configSections/AdminMcpSection";
import AdminAiGeneralSection from "@app/components/shared/config/configSections/AdminAiGeneralSection";
import AdminAiModelsSection from "@app/components/shared/config/configSections/AdminAiModelsSection";
import AdminAiDocumentsSection from "@app/components/shared/config/configSections/AdminAiDocumentsSection";
import AdminAiLimitsSection from "@app/components/shared/config/configSections/AdminAiLimitsSection";
import AdminAuditSection from "@app/components/shared/config/configSections/AdminAuditSection";
import AdminUsageSection from "@app/components/shared/config/configSections/AdminUsageSection";
import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection";
@@ -163,6 +167,45 @@ export const useConfigNavSections = (
],
});
// AI
sections.push({
title: t("settings.ai.title", "AI"),
items: [
{
key: "adminAiGeneral",
label: t("settings.ai.general", "General"),
icon: "smart-toy-rounded",
component: <AdminAiGeneralSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminAiModels",
label: t("settings.ai.models", "Models & Providers"),
icon: "psychology-rounded",
component: <AdminAiModelsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminAiDocuments",
label: t("settings.ai.documents", "Documents & RAG"),
icon: "description-rounded",
component: <AdminAiDocumentsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminAiLimits",
label: t("settings.ai.limits", "Limits & Performance"),
icon: "speed-rounded",
component: <AdminAiLimitsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
// Security & Authentication
sections.push({
title: t("settings.securityAuth.title", "Security & Authentication"),
@@ -0,0 +1,343 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
NumberInput,
PasswordInput,
Autocomplete,
Select,
TextInput,
Stack,
Paper,
Text,
Loader,
Group,
Alert,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import {
AiEngineSettingsData,
AiEngineRag,
AiEngineApiResponse,
EMBEDDING_MODEL_SUGGESTIONS,
} from "@app/components/shared/config/configSections/aiEngineSettings";
export default function AdminAiDocumentsSection() {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
// Track edits to the masked secret so we never send "********" back.
const [embeddingApiKeyDirty, setEmbeddingApiKeyDirty] = useState(false);
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<AiEngineSettingsData>({
sectionName: "aiEngine",
fetchTransformer: async (): Promise<
AiEngineSettingsData & { _pending?: Partial<AiEngineSettingsData> }
> => {
const response = await apiClient.get<AiEngineApiResponse>(
"/api/v1/admin/settings/section/aiEngine",
);
return response.data || {};
},
// Save ONLY this page's keys as dot-notation so sibling AI keys are preserved.
saveTransformer: (s: AiEngineSettingsData) => {
const deltaSettings: Record<string, unknown> = {
"aiEngine.rag.embeddingProvider":
s.rag?.embeddingProvider ?? "voyageai",
"aiEngine.rag.embeddingModel": s.rag?.embeddingModel ?? "",
"aiEngine.rag.embeddingBaseUrl": s.rag?.embeddingBaseUrl ?? "",
"aiEngine.rag.topK": s.rag?.topK ?? 0,
"aiEngine.rag.maxSearches": s.rag?.maxSearches ?? 0,
};
if (embeddingApiKeyDirty) {
deltaSettings["aiEngine.rag.embeddingApiKey"] =
s.rag?.embeddingApiKey ?? "";
}
return { sectionData: {}, deltaSettings };
},
});
useEffect(() => {
fetchSettings();
}, []);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
try {
await saveSettings();
setEmbeddingApiKeyDirty(false);
markSaved();
// Engine-facing values are pushed to the AI engine live on save; no restart needed.
alert({
alertType: "success",
title: t("admin.settings.ai.saved.title", "AI settings saved"),
body: t(
"admin.settings.ai.saved.body",
"Changes are pushed to the AI engine automatically.",
),
});
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
setEmbeddingApiKeyDirty(false);
setSettings(resetToSnapshot());
}, [resetToSnapshot, setSettings]);
const setRag = (patch: Partial<AiEngineRag>) =>
setSettings({ ...settings, rag: { ...(settings.rag || {}), ...patch } });
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
const embeddingProvider = settings.rag?.embeddingProvider || "voyageai";
const embeddingSuggestions =
EMBEDDING_MODEL_SUGGESTIONS[embeddingProvider] || [];
const showEmbeddingBaseUrl =
embeddingProvider === "ollama" || embeddingProvider === "custom";
// Ollama's embeddings endpoint needs no API key, mirroring the Models page.
const showEmbeddingApiKey = embeddingProvider !== "ollama";
const embeddingApiKeyLabel =
embeddingProvider === "voyageai"
? t(
"admin.settings.ai.documents.embeddingApiKey.voyageai",
"VoyageAI API key",
)
: embeddingProvider === "openai"
? t(
"admin.settings.ai.documents.embeddingApiKey.openai",
"OpenAI API key",
)
: t(
"admin.settings.ai.documents.embeddingApiKey.generic",
"Embedding API key",
);
const embeddingApiKeyPlaceholder =
embeddingProvider === "voyageai"
? "pa-..."
: embeddingProvider === "openai"
? "sk-..."
: "";
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<div>
<Text fw={600} size="lg">
{t("admin.settings.ai.documents.title", "Documents & RAG")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.ai.documents.description",
"Configure the embedding model and retrieval settings used to answer questions over documents. Applied to the AI engine when saved.",
)}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Select
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.documents.embeddingProvider.label",
"Embedding provider",
)}
</span>
<PendingBadge
show={isFieldPending("rag.embeddingProvider")}
/>
</Group>
}
description={t(
"admin.settings.ai.documents.embeddingProvider.description",
"Provider used to turn document text into vector embeddings.",
)}
data={[
{ value: "voyageai", label: "VoyageAI" },
{ value: "openai", label: "OpenAI" },
{ value: "ollama", label: "Ollama" },
{ value: "custom", label: "Custom (OpenAI-compatible)" },
]}
value={embeddingProvider}
onChange={(v) => setRag({ embeddingProvider: v || "voyageai" })}
allowDeselect={false}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<Autocomplete
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.documents.embeddingModel.label",
"Embedding model",
)}
</span>
<PendingBadge show={isFieldPending("rag.embeddingModel")} />
</Group>
}
description={t(
"admin.settings.ai.documents.embeddingModel.description",
"Embedding model name. Free text; suggestions are hints only.",
)}
data={embeddingSuggestions}
value={settings.rag?.embeddingModel || ""}
onChange={(value) => setRag({ embeddingModel: value })}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
{showEmbeddingApiKey && (
<PasswordInput
label={
<Group gap="xs">
<span>{embeddingApiKeyLabel}</span>
<PendingBadge
show={isFieldPending("rag.embeddingApiKey")}
/>
</Group>
}
description={t(
"admin.settings.ai.documents.embeddingApiKey.description",
"Leave blank to use the engine's own environment credential. Applies to self-hosted single-engine deployments.",
)}
value={settings.rag?.embeddingApiKey || ""}
onChange={(e) => {
setEmbeddingApiKeyDirty(true);
setRag({ embeddingApiKey: e.target.value });
}}
placeholder={embeddingApiKeyPlaceholder}
/>
)}
{showEmbeddingBaseUrl && (
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.documents.embeddingBaseUrl.label",
"Embedding base URL",
)}
</span>
<PendingBadge
show={isFieldPending("rag.embeddingBaseUrl")}
/>
</Group>
}
description={t(
"admin.settings.ai.documents.embeddingBaseUrl.description",
"Base URL of the OpenAI-compatible / Ollama embeddings endpoint, e.g. http://ollama:11434/v1. Must point at a trusted internal endpoint (SSRF-sensitive).",
)}
value={settings.rag?.embeddingBaseUrl || ""}
onChange={(e) => setRag({ embeddingBaseUrl: e.target.value })}
placeholder="http://ollama:11434/v1"
/>
)}
<NumberInput
label={
<Group gap="xs">
<span>
{t("admin.settings.ai.documents.topK.label", "Top K")}
</span>
<PendingBadge show={isFieldPending("rag.topK")} />
</Group>
}
description={t(
"admin.settings.ai.documents.topK.description",
"Number of most-relevant chunks retrieved per search.",
)}
value={settings.rag?.topK ?? 0}
onChange={(value) => setRag({ topK: Number(value) })}
min={1}
/>
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.documents.maxSearches.label",
"Max searches",
)}
</span>
<PendingBadge show={isFieldPending("rag.maxSearches")} />
</Group>
}
description={t(
"admin.settings.ai.documents.maxSearches.description",
"Maximum number of retrieval searches the agent may run per request.",
)}
value={settings.rag?.maxSearches ?? 0}
onChange={(value) => setRag({ maxSearches: Number(value) })}
min={0}
/>
</Stack>
</Paper>
<Alert
variant="light"
color="orange"
title={t(
"admin.settings.ai.documents.reindexNote.title",
"Re-index required",
)}
icon={<LocalIcon icon="warning-rounded" width="1rem" height="1rem" />}
>
<Text size="xs">
{t(
"admin.settings.ai.documents.reindexNote.body",
"Changing the embedding model invalidates previously-embedded documents. A full engine restart and re-index of existing documents is required for search to work correctly.",
)}
</Text>
</Alert>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</div>
);
}
@@ -0,0 +1,500 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
TextInput,
NumberInput,
Switch,
Stack,
Paper,
Text,
Loader,
Group,
Alert,
Code,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import LocalIcon from "@app/components/shared/LocalIcon";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import { Button } from "@app/ui/Button";
import {
AiEngineSettingsData,
AiEngineFeatures,
AiEngineApiResponse,
} from "@app/components/shared/config/configSections/aiEngineSettings";
export default function AdminAiGeneralSection() {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<AiEngineSettingsData>({
sectionName: "aiEngine",
fetchTransformer: async (): Promise<
AiEngineSettingsData & { _pending?: Partial<AiEngineSettingsData> }
> => {
const response = await apiClient.get<AiEngineApiResponse>(
"/api/v1/admin/settings/section/aiEngine",
);
return response.data || {};
},
// Save ONLY this page's keys as dot-notation so sibling AI keys are preserved.
saveTransformer: (s: AiEngineSettingsData) => ({
sectionData: {},
deltaSettings: {
"aiEngine.enabled": s.enabled ?? false,
"aiEngine.url": s.url ?? "",
"aiEngine.timeoutSeconds": s.timeoutSeconds ?? 0,
"aiEngine.longRunningTimeoutSeconds": s.longRunningTimeoutSeconds ?? 0,
"aiEngine.streamTimeoutSeconds": s.streamTimeoutSeconds ?? 0,
"aiEngine.features.chat": s.features?.chat ?? false,
"aiEngine.features.documentQuestions":
s.features?.documentQuestions ?? false,
"aiEngine.features.createPdf": s.features?.createPdf ?? false,
"aiEngine.features.mathAuditor": s.features?.mathAuditor ?? false,
"aiEngine.features.pdfComment": s.features?.pdfComment ?? false,
"aiEngine.features.classify": s.features?.classify ?? false,
},
}),
});
useEffect(() => {
fetchSettings();
}, []);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
try {
await saveSettings();
markSaved();
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
setSettings(resetToSnapshot());
}, [resetToSnapshot, setSettings]);
const [testingConnection, setTestingConnection] = useState(false);
// Probe the RUNNING configuration (the Java bean), not unsaved form values -
// after enabling AI or changing the URL, save + restart first, then test.
const handleTestConnection = async () => {
setTestingConnection(true);
try {
await apiClient.get("/api/v1/ai/health");
alert({
alertType: "success",
title: t(
"admin.settings.ai.general.test.okTitle",
"AI engine reachable",
),
body: t(
"admin.settings.ai.general.test.okBody",
"The AI engine responded to a health check.",
),
});
} catch (error) {
const detail =
(error as { response?: { data?: { message?: string } } })?.response
?.data?.message ||
t(
"admin.settings.ai.general.test.failBody",
"The AI engine did not respond. Check the URL, that the engine container is running, and that AI is enabled (a restart is needed after enabling).",
);
alert({
alertType: "error",
title: t(
"admin.settings.ai.general.test.failTitle",
"AI engine unreachable",
),
body: detail,
});
} finally {
setTestingConnection(false);
}
};
const setFeatures = (patch: Partial<AiEngineFeatures>) =>
setSettings({
...settings,
features: { ...(settings.features || {}), ...patch },
});
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
const enabled = settings.enabled || false;
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<div>
<Text fw={600} size="lg">
{t("admin.settings.ai.general.title", "AI Engine")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.ai.general.description",
"Connect Stirling to the Python AI engine and choose which AI capabilities are exposed. Changes apply on restart.",
)}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">
{t("admin.settings.ai.general.enabled.label", "Enable AI")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.ai.general.enabled.description",
"Master switch. When off, no AI tools, agents, or engine calls are available.",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={enabled}
onChange={(e) =>
setSettings({ ...settings, enabled: e.target.checked })
}
/>
<PendingBadge show={isFieldPending("enabled")} />
</Group>
</Group>
<TextInput
label={
<Group gap="xs">
<span>
{t("admin.settings.ai.general.url.label", "AI engine URL")}
</span>
<PendingBadge show={isFieldPending("url")} />
</Group>
}
description={t(
"admin.settings.ai.general.url.description",
"Internal URL of the Python AI engine, e.g. http://stirling-pdf-engine:5001.",
)}
value={settings.url || ""}
onChange={(e) =>
setSettings({ ...settings, url: e.target.value })
}
placeholder="http://stirling-pdf-engine:5001"
disabled={!enabled}
/>
<Group justify="flex-end">
<Button
variant="secondary"
size="sm"
loading={testingConnection}
onClick={handleTestConnection}
>
{t("admin.settings.ai.general.test.button", "Test connection")}
</Button>
</Group>
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.general.timeoutSeconds.label",
"Request timeout (seconds)",
)}
</span>
<PendingBadge show={isFieldPending("timeoutSeconds")} />
</Group>
}
description={t(
"admin.settings.ai.general.timeoutSeconds.description",
"Timeout for standard AI requests to the engine.",
)}
value={settings.timeoutSeconds ?? 0}
onChange={(value) =>
setSettings({ ...settings, timeoutSeconds: Number(value) })
}
min={1}
disabled={!enabled}
/>
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.general.longRunningTimeoutSeconds.label",
"Long-running timeout (seconds)",
)}
</span>
<PendingBadge
show={isFieldPending("longRunningTimeoutSeconds")}
/>
</Group>
}
description={t(
"admin.settings.ai.general.longRunningTimeoutSeconds.description",
"Timeout for heavier agent operations such as document generation.",
)}
value={settings.longRunningTimeoutSeconds ?? 0}
onChange={(value) =>
setSettings({
...settings,
longRunningTimeoutSeconds: Number(value),
})
}
min={1}
disabled={!enabled}
/>
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.general.streamTimeoutSeconds.label",
"Stream timeout (seconds)",
)}
</span>
<PendingBadge show={isFieldPending("streamTimeoutSeconds")} />
</Group>
}
description={t(
"admin.settings.ai.general.streamTimeoutSeconds.description",
"Timeout for streamed (token-by-token) chat responses.",
)}
value={settings.streamTimeoutSeconds ?? 0}
onChange={(value) =>
setSettings({
...settings,
streamTimeoutSeconds: Number(value),
})
}
min={1}
disabled={!enabled}
/>
</Stack>
</Paper>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm">
{t(
"admin.settings.ai.general.capabilities.title",
"Capabilities",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.ai.general.capabilities.description",
"Turn individual AI features on or off. Disabled features are hidden in the app.",
)}
</Text>
</div>
<FeatureSwitch
label={t(
"admin.settings.ai.general.features.chat.label",
"Chat assistant",
)}
description={t(
"admin.settings.ai.general.features.chat.description",
"Conversational assistant for working with PDFs.",
)}
checked={settings.features?.chat ?? false}
onChange={(checked) => setFeatures({ chat: checked })}
pending={isFieldPending("features.chat")}
disabled={!enabled}
/>
<FeatureSwitch
label={t(
"admin.settings.ai.general.features.documentQuestions.label",
"Document questions",
)}
description={t(
"admin.settings.ai.general.features.documentQuestions.description",
"Ask questions and get answers grounded in an uploaded document.",
)}
checked={settings.features?.documentQuestions ?? false}
onChange={(checked) =>
setFeatures({ documentQuestions: checked })
}
pending={isFieldPending("features.documentQuestions")}
disabled={!enabled}
/>
<FeatureSwitch
label={t(
"admin.settings.ai.general.features.createPdf.label",
"Create PDF from prompt",
)}
description={t(
"admin.settings.ai.general.features.createPdf.description",
"Generate a new PDF (e.g. from HTML) via an AI agent.",
)}
checked={settings.features?.createPdf ?? false}
onChange={(checked) => setFeatures({ createPdf: checked })}
pending={isFieldPending("features.createPdf")}
disabled={!enabled}
/>
<FeatureSwitch
label={t(
"admin.settings.ai.general.features.mathAuditor.label",
"Math auditor",
)}
description={t(
"admin.settings.ai.general.features.mathAuditor.description",
"Review documents for mathematical and numerical errors.",
)}
checked={settings.features?.mathAuditor ?? false}
onChange={(checked) => setFeatures({ mathAuditor: checked })}
pending={isFieldPending("features.mathAuditor")}
disabled={!enabled}
/>
<FeatureSwitch
label={t(
"admin.settings.ai.general.features.pdfComment.label",
"PDF comment agent",
)}
description={t(
"admin.settings.ai.general.features.pdfComment.description",
"Add AI-authored review comments and annotations to a PDF.",
)}
checked={settings.features?.pdfComment ?? false}
onChange={(checked) => setFeatures({ pdfComment: checked })}
pending={isFieldPending("features.pdfComment")}
disabled={!enabled}
/>
<FeatureSwitch
label={t(
"admin.settings.ai.general.features.classify.label",
"Document classification",
)}
description={t(
"admin.settings.ai.general.features.classify.description",
"Automatically categorise documents by type or content.",
)}
checked={settings.features?.classify ?? false}
onChange={(checked) => setFeatures({ classify: checked })}
pending={isFieldPending("features.classify")}
disabled={!enabled}
/>
</Stack>
</Paper>
<Alert
variant="light"
color="blue"
title={t(
"admin.settings.ai.general.note.title",
"About the AI engine",
)}
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="xs">
{t(
"admin.settings.ai.general.note.body",
"The AI engine runs as a separate service. Its shared secret",
)}{" "}
<Code>STIRLING_ENGINE_SHARED_SECRET</Code>{" "}
{t(
"admin.settings.ai.general.note.body2",
"is set via a container environment variable. Provider API keys can be entered on these pages or supplied as engine environment variables; keys entered here are pushed to the engine when saved.",
)}
</Text>
</Alert>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
interface FeatureSwitchProps {
label: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
pending: boolean;
disabled: boolean;
}
function FeatureSwitch({
label,
description,
checked,
onChange,
pending,
disabled,
}: FeatureSwitchProps) {
return (
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">
{label}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{description}
</Text>
</div>
<Group gap="xs">
<Switch
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
<PendingBadge show={pending} />
</Group>
</Group>
);
}
@@ -0,0 +1,196 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { NumberInput, Stack, Paper, Text, Loader, Group } from "@mantine/core";
import { alert } from "@app/components/toast";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import {
AiEngineSettingsData,
AiEngineLimits,
AiEngineApiResponse,
} from "@app/components/shared/config/configSections/aiEngineSettings";
export default function AdminAiLimitsSection() {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<AiEngineSettingsData>({
sectionName: "aiEngine",
fetchTransformer: async (): Promise<
AiEngineSettingsData & { _pending?: Partial<AiEngineSettingsData> }
> => {
const response = await apiClient.get<AiEngineApiResponse>(
"/api/v1/admin/settings/section/aiEngine",
);
return response.data || {};
},
// Save ONLY this page's keys as dot-notation so sibling AI keys are preserved.
saveTransformer: (s: AiEngineSettingsData) => ({
sectionData: {},
deltaSettings: {
"aiEngine.limits.maxPages": s.limits?.maxPages ?? 0,
"aiEngine.limits.maxCharacters": s.limits?.maxCharacters ?? 0,
"aiEngine.limits.modelMaxConcurrency":
s.limits?.modelMaxConcurrency ?? 0,
},
}),
});
useEffect(() => {
fetchSettings();
}, []);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
try {
await saveSettings();
markSaved();
// Engine-facing values are pushed to the AI engine live on save; no restart needed.
alert({
alertType: "success",
title: t("admin.settings.ai.saved.title", "AI settings saved"),
body: t(
"admin.settings.ai.saved.body",
"Changes are pushed to the AI engine automatically.",
),
});
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
setSettings(resetToSnapshot());
}, [resetToSnapshot, setSettings]);
const setLimits = (patch: Partial<AiEngineLimits>) =>
setSettings({
...settings,
limits: { ...(settings.limits || {}), ...patch },
});
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<div>
<Text fw={600} size="lg">
{t("admin.settings.ai.limits.title", "Limits & Performance")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.ai.limits.description",
"Guardrails for how much work AI requests may do and how many run concurrently. Applied to the AI engine when saved.",
)}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.limits.maxPages.label",
"Max pages per request",
)}
</span>
<PendingBadge show={isFieldPending("limits.maxPages")} />
</Group>
}
description={t(
"admin.settings.ai.limits.maxPages.description",
"Guardrail: reject AI requests over this many PDF pages.",
)}
value={settings.limits?.maxPages ?? 0}
onChange={(value) => setLimits({ maxPages: Number(value) })}
min={1}
/>
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.limits.maxCharacters.label",
"Max characters per request",
)}
</span>
<PendingBadge show={isFieldPending("limits.maxCharacters")} />
</Group>
}
description={t(
"admin.settings.ai.limits.maxCharacters.description",
"Guardrail: reject AI requests whose extracted text exceeds this length.",
)}
value={settings.limits?.maxCharacters ?? 0}
onChange={(value) => setLimits({ maxCharacters: Number(value) })}
min={1}
/>
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.limits.modelMaxConcurrency.label",
"Model max concurrency",
)}
</span>
<PendingBadge
show={isFieldPending("limits.modelMaxConcurrency")}
/>
</Group>
}
description={t(
"admin.settings.ai.limits.modelMaxConcurrency.description",
"Maximum simultaneous in-flight model calls across the whole engine.",
)}
value={settings.limits?.modelMaxConcurrency ?? 0}
onChange={(value) =>
setLimits({ modelMaxConcurrency: Number(value) })
}
min={1}
/>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</div>
);
}
@@ -0,0 +1,361 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
TextInput,
NumberInput,
PasswordInput,
Autocomplete,
Select,
Stack,
Paper,
Text,
Loader,
Group,
Alert,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import {
AiEngineSettingsData,
AiEngineModels,
AiEngineApiResponse,
MODEL_SUGGESTIONS,
} from "@app/components/shared/config/configSections/aiEngineSettings";
export default function AdminAiModelsSection() {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
// Track whether the user actually edited the masked secret. If not, we omit
// it from the delta entirely so we never send "********" back to the server.
const [apiKeyDirty, setApiKeyDirty] = useState(false);
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<AiEngineSettingsData>({
sectionName: "aiEngine",
fetchTransformer: async (): Promise<
AiEngineSettingsData & { _pending?: Partial<AiEngineSettingsData> }
> => {
const response = await apiClient.get<AiEngineApiResponse>(
"/api/v1/admin/settings/section/aiEngine",
);
return response.data || {};
},
// Save ONLY this page's keys as dot-notation so sibling AI keys are preserved.
saveTransformer: (s: AiEngineSettingsData) => {
const deltaSettings: Record<string, unknown> = {
"aiEngine.models.provider": s.models?.provider ?? "anthropic",
"aiEngine.models.smartModel": s.models?.smartModel ?? "",
"aiEngine.models.fastModel": s.models?.fastModel ?? "",
"aiEngine.models.smartMaxTokens": s.models?.smartMaxTokens ?? 0,
"aiEngine.models.fastMaxTokens": s.models?.fastMaxTokens ?? 0,
"aiEngine.models.baseUrl": s.models?.baseUrl ?? "",
};
// Only include the secret when the user typed a new value.
if (apiKeyDirty) {
deltaSettings["aiEngine.models.apiKey"] = s.models?.apiKey ?? "";
}
return { sectionData: {}, deltaSettings };
},
});
useEffect(() => {
fetchSettings();
}, []);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
try {
await saveSettings();
setApiKeyDirty(false);
markSaved();
// Engine-facing values are pushed to the AI engine live on save; no restart needed.
alert({
alertType: "success",
title: t("admin.settings.ai.saved.title", "AI settings saved"),
body: t(
"admin.settings.ai.saved.body",
"Changes are pushed to the AI engine automatically.",
),
});
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
setApiKeyDirty(false);
setSettings(resetToSnapshot());
}, [resetToSnapshot, setSettings]);
const setModels = (patch: Partial<AiEngineModels>) =>
setSettings({
...settings,
models: { ...(settings.models || {}), ...patch },
});
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
const provider = settings.models?.provider || "anthropic";
const showApiKey = provider !== "ollama";
const showBaseUrl = provider === "ollama" || provider === "custom";
const modelSuggestions = MODEL_SUGGESTIONS[provider] || [];
const apiKeyLabel =
provider === "anthropic"
? t("admin.settings.ai.models.apiKey.anthropic", "Anthropic API key")
: provider === "openai"
? t("admin.settings.ai.models.apiKey.openai", "OpenAI API key")
: t("admin.settings.ai.models.apiKey.generic", "API key");
const apiKeyPlaceholder =
provider === "anthropic"
? "sk-ant-..."
: provider === "openai"
? "sk-..."
: "";
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<div>
<Text fw={600} size="lg">
{t("admin.settings.ai.models.title", "Models & Providers")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.ai.models.description",
"Choose the LLM provider and the smart/fast models the AI engine uses. Applied to the AI engine when saved.",
)}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Select
label={
<Group gap="xs">
<span>
{t("admin.settings.ai.models.provider.label", "Provider")}
</span>
<PendingBadge show={isFieldPending("models.provider")} />
</Group>
}
description={t(
"admin.settings.ai.models.provider.description",
"Which LLM provider the engine talks to.",
)}
data={[
{ value: "anthropic", label: "Anthropic" },
{ value: "openai", label: "OpenAI" },
{ value: "ollama", label: "Ollama" },
{ value: "custom", label: "Custom (OpenAI-compatible)" },
]}
value={provider}
onChange={(v) => setModels({ provider: v || "anthropic" })}
allowDeselect={false}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<Autocomplete
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.models.smartModel.label",
"Smart model",
)}
</span>
<PendingBadge show={isFieldPending("models.smartModel")} />
</Group>
}
description={t(
"admin.settings.ai.models.smartModel.description",
"High-capability model for complex reasoning. Free text; suggestions are hints only.",
)}
data={modelSuggestions}
value={settings.models?.smartModel || ""}
onChange={(value) => setModels({ smartModel: value })}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<Autocomplete
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.models.fastModel.label",
"Fast model",
)}
</span>
<PendingBadge show={isFieldPending("models.fastModel")} />
</Group>
}
description={t(
"admin.settings.ai.models.fastModel.description",
"Cheaper, faster model for lightweight tasks. Free text; suggestions are hints only.",
)}
data={modelSuggestions}
value={settings.models?.fastModel || ""}
onChange={(value) => setModels({ fastModel: value })}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.models.smartMaxTokens.label",
"Smart model max tokens",
)}
</span>
<PendingBadge
show={isFieldPending("models.smartMaxTokens")}
/>
</Group>
}
description={t(
"admin.settings.ai.models.smartMaxTokens.description",
"Maximum output tokens for the smart model.",
)}
value={settings.models?.smartMaxTokens ?? 0}
onChange={(value) => setModels({ smartMaxTokens: Number(value) })}
min={1}
/>
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.models.fastMaxTokens.label",
"Fast model max tokens",
)}
</span>
<PendingBadge show={isFieldPending("models.fastMaxTokens")} />
</Group>
}
description={t(
"admin.settings.ai.models.fastMaxTokens.description",
"Maximum output tokens for the fast model.",
)}
value={settings.models?.fastMaxTokens ?? 0}
onChange={(value) => setModels({ fastMaxTokens: Number(value) })}
min={1}
/>
{showApiKey && (
<PasswordInput
label={
<Group gap="xs">
<span>{apiKeyLabel}</span>
<PendingBadge show={isFieldPending("models.apiKey")} />
</Group>
}
description={t(
"admin.settings.ai.models.apiKey.description",
"Leave blank to use the engine's own environment credential. Applies to self-hosted single-engine deployments.",
)}
value={settings.models?.apiKey || ""}
onChange={(e) => {
setApiKeyDirty(true);
setModels({ apiKey: e.target.value });
}}
placeholder={apiKeyPlaceholder}
/>
)}
{showBaseUrl && (
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.models.baseUrl.label",
"Provider base URL",
)}
</span>
<PendingBadge show={isFieldPending("models.baseUrl")} />
</Group>
}
description={t(
"admin.settings.ai.models.baseUrl.description",
"Base URL of the OpenAI-compatible / Ollama endpoint, e.g. http://ollama:11434/v1.",
)}
value={settings.models?.baseUrl || ""}
onChange={(e) => setModels({ baseUrl: e.target.value })}
placeholder="http://ollama:11434/v1"
/>
)}
{showBaseUrl && (
<Alert
variant="light"
color="orange"
icon={
<LocalIcon
icon="warning-rounded"
width="1rem"
height="1rem"
/>
}
>
<Text size="xs">
{t(
"admin.settings.ai.models.baseUrl.warning",
"The base URL must point at a trusted internal endpoint. The engine will make server-side requests to it, so an untrusted value is SSRF-sensitive.",
)}
</Text>
</Alert>
)}
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</div>
);
}
@@ -0,0 +1,75 @@
// Shared types + constants for the aiEngine admin settings section.
// The four AI sub-pages fetch the same GET /section/aiEngine payload but each
// saves only its own dot-notation keys so sibling keys are preserved.
/** Secret fields come back masked as this literal when a value is set. */
export const MASKED_SECRET = "********";
export interface AiEngineModels {
provider?: string;
smartModel?: string;
fastModel?: string;
smartMaxTokens?: number;
fastMaxTokens?: number;
apiKey?: string;
baseUrl?: string;
}
export interface AiEngineRag {
embeddingProvider?: string;
embeddingModel?: string;
embeddingApiKey?: string;
embeddingBaseUrl?: string;
topK?: number;
maxSearches?: number;
}
export interface AiEngineLimits {
maxPages?: number;
maxCharacters?: number;
modelMaxConcurrency?: number;
}
export interface AiEngineFeatures {
chat?: boolean;
documentQuestions?: boolean;
createPdf?: boolean;
mathAuditor?: boolean;
pdfComment?: boolean;
classify?: boolean;
}
export interface AiEngineSettingsData {
enabled?: boolean;
url?: string;
timeoutSeconds?: number;
longRunningTimeoutSeconds?: number;
streamTimeoutSeconds?: number;
models?: AiEngineModels;
rag?: AiEngineRag;
limits?: AiEngineLimits;
features?: AiEngineFeatures;
}
export interface ApiResponseWithPending<T> {
_pending?: Partial<T>;
}
export type AiEngineApiResponse = AiEngineSettingsData &
ApiResponseWithPending<AiEngineSettingsData>;
/** Free-text model suggestions per provider (hints only). */
export const MODEL_SUGGESTIONS: Record<string, string[]> = {
anthropic: ["claude-haiku-4-5", "claude-sonnet-4-5", "claude-opus-4-1"],
openai: ["gpt-4o", "gpt-4o-mini", "o3-mini"],
ollama: ["llama3.1", "qwen2.5", "mistral"],
custom: ["llama3.1", "qwen2.5", "mistral"],
};
/** Free-text embedding-model suggestions per embedding provider. */
export const EMBEDDING_MODEL_SUGGESTIONS: Record<string, string[]> = {
voyageai: ["voyage-4", "voyage-3.5"],
openai: ["text-embedding-3-small", "text-embedding-3-large"],
ollama: ["nomic-embed-text", "mxbai-embed-large", "bge-m3"],
custom: ["nomic-embed-text", "mxbai-embed-large", "bge-m3"],
};
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { type TFunction } from "i18next";
import { allowConsole } from "@app/tests/failOnConsole";
import { createSaasConfigNavSections } from "@app/components/shared/config/saasConfigNavSections";
// Passthrough i18n stub: return the provided fallback (2nd arg) or the key.
const t = ((key: string, fallback?: string) =>
fallback ?? key) as unknown as TFunction<"translation", undefined>;
const Overview = () => null;
type Sections = ReturnType<typeof createSaasConfigNavSections>;
function itemKeys(sections: Sections): string[] {
return sections.flatMap((s) => s.items.map((i) => i.key));
}
// The admin AI settings pages exist only in the self-hosted proprietary flavor. SaaS builds its
// settings nav from scratch here, so this locks in that the AI group can never leak into SaaS -
// if someone later wires the AI sections into the SaaS cascade, this fails loudly.
describe("saasConfigNavSections", () => {
const AI_ITEM_KEYS = [
"adminAiGeneral",
"adminAiModels",
"adminAiDocuments",
"adminAiLimits",
];
it("never exposes the admin AI settings group or its pages", () => {
// The shared core nav helper warns it is deprecated; incidental to this test.
allowConsole.warn(/createConfigNavSections is deprecated/);
const sections = createSaasConfigNavSections(Overview, () => {}, { t });
const keys = itemKeys(sections);
for (const aiKey of AI_ITEM_KEYS) {
expect(keys).not.toContain(aiKey);
}
expect(sections.map((s) => s.title)).not.toContain("AI");
});
it("also hides the AI pages for anonymous users", () => {
allowConsole.warn(/createConfigNavSections is deprecated/);
const sections = createSaasConfigNavSections(Overview, () => {}, {
t,
isAnonymous: true,
});
const keys = itemKeys(sections);
for (const aiKey of AI_ITEM_KEYS) {
expect(keys).not.toContain(aiKey);
}
});
});
@@ -1,86 +0,0 @@
@jwt @auth @apikey
Feature: API Keys management API
Tests for the portal API-keys REST API, which lets a user mint, list, and
revoke named personal API keys.
Endpoints (all @EnterpriseEndpoint, JWT/ROLE required):
- GET /api/v1/proprietary/ui-data/infrastructure/api-keys (list)
- POST /api/v1/proprietary/ui-data/infrastructure/api-keys (create)
- DELETE /api/v1/proprietary/ui-data/infrastructure/api-keys/{id} (revoke)
Because these are @EnterpriseEndpoint, authenticated responses may be 200
(enterprise enabled) or 403 (feature not in this build). Unauthenticated
requests must always be rejected with 401.
The legacy single per-user key (the global API key) must keep working so no
key created before multi-key support is ever lost.
Admin credentials: username=admin, password=stirling
Global API key: 123456789
# =========================================================================
# LIST
# =========================================================================
@positive
Scenario: Admin can list API keys
Given I am logged in as admin
When I send a GET request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication
Then the response status code should be one of "200, 403"
@negative
Scenario: Unauthenticated list request returns 401
When I send a GET request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with no authentication
Then the response status code should be 401
# =========================================================================
# CREATE
# =========================================================================
@positive
Scenario: Admin can create a personal API key
Given I am logged in as admin
When I send a JSON POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication and body '{"name": "bdd_personal_key"}'
Then the response status code should be one of "200, 403"
@negative
Scenario: Creating a key without a name is rejected
Given I am logged in as admin
When I send a JSON POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication and body '{"name": ""}'
Then the response status code should be one of "400, 403"
@negative
Scenario: Unauthenticated create request returns 401
When I send a POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with no authentication
Then the response status code should be 401
# =========================================================================
# REVOKE
# =========================================================================
@negative
Scenario: Unauthenticated revoke request returns 401
When I send a DELETE request to "/api/v1/proprietary/ui-data/infrastructure/api-keys/1" with no authentication
Then the response status code should be 401
@positive
Scenario: Admin revoking a non-existent key is handled, not a bypass
Given I am logged in as admin
When I send a DELETE request to "/api/v1/proprietary/ui-data/infrastructure/api-keys/999999" with JWT authentication
Then the response status code should be one of "204, 403, 404"
# =========================================================================
# LEGACY KEY BACKWARD COMPATIBILITY
# =========================================================================
@positive
Scenario: The legacy global API key still authenticates
When I send a GET request to "/api/v1/auth/me" with API key "123456789"
Then the response status code should be 200
And the response JSON should have field "username"
@negative
Scenario: An unknown API key is rejected
When I send a GET request to "/api/v1/auth/me" with API key "not-a-real-api-key-000"
Then the response status code should be 401