Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1143a8aba3 | ||
|
|
baf33b6844 | ||
|
|
776749277c | ||
|
|
41b1b89fcb | ||
|
|
4d4e994562 | ||
|
|
2b118556f3 | ||
|
|
fbaff56d1c | ||
|
|
a1b15e0570 | ||
|
|
76549288a9 | ||
|
|
b5d0c4a5ed | ||
|
|
8bfcf6eb7e | ||
|
|
b019f9b570 | ||
|
|
38ccea074c | ||
|
|
a7307ff393 | ||
|
|
72729e99c1 | ||
|
|
5fba2720f0 | ||
|
|
01a1ef8c44 | ||
|
|
8535c7e9ac | ||
|
|
cca3f42623 | ||
|
|
1b7ffcdbac | ||
|
|
67a0ca6110 | ||
|
|
8e4b2e2fc6 | ||
|
|
3c93457021 |
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.14.1
|
||||
pkgver=2.14.2
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.14.1
|
||||
pkgver=2.14.2
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -510,6 +510,7 @@ jobs:
|
||||
# cargo output unsigned, so checking it produces false negatives.
|
||||
- name: Verify Windows Code Signature
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
timeout-minutes: 15
|
||||
shell: pwsh
|
||||
run: |
|
||||
$allSigned = $true
|
||||
@@ -531,11 +532,26 @@ jobs:
|
||||
|
||||
# Extract MSI and verify the inner exe (the file that actually gets installed).
|
||||
# This is the critical check - AV flags the installed exe at runtime.
|
||||
# Use lessmsi, not `msiexec /a`: msiexec serializes on the global
|
||||
# _MSIExecute mutex and hangs forever on hosted runners when another
|
||||
# installer is busy. lessmsi reads MSI tables directly - no mutex, no service.
|
||||
$msi = $msiFiles[0].FullName
|
||||
$extractDir = Join-Path $env:RUNNER_TEMP "msi-verify"
|
||||
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
|
||||
$proc = Start-Process msiexec.exe -ArgumentList '/a', $msi, '/qn', "TARGETDIR=$extractDir" -Wait -PassThru -NoNewWindow
|
||||
if ($proc.ExitCode -eq 0) {
|
||||
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
|
||||
|
||||
choco install lessmsi -y --no-progress --limit-output | Out-Null
|
||||
|
||||
# Bound the extraction and kill on hang (defence in depth over timeout-minutes).
|
||||
$proc = Start-Process lessmsi -ArgumentList 'x', "`"$msi`"", "`"$extractDir\`"" -PassThru -NoNewWindow
|
||||
if (-not $proc.WaitForExit(120000)) {
|
||||
try { $proc.Kill() } catch {}
|
||||
Write-Host "[ERROR] MSI extraction timed out after 120s"
|
||||
$allSigned = $false
|
||||
} elseif ($proc.ExitCode -ne 0) {
|
||||
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
|
||||
$allSigned = $false
|
||||
} else {
|
||||
$innerExe = Get-ChildItem -Path $extractDir -Filter "stirling-pdf.exe" -Recurse -File | Select-Object -First 1
|
||||
if ($innerExe) {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $innerExe.FullName
|
||||
@@ -548,9 +564,6 @@ jobs:
|
||||
Write-Host "[ERROR] Could not find stirling-pdf.exe inside MSI"
|
||||
$allSigned = $false
|
||||
}
|
||||
} else {
|
||||
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
|
||||
$allSigned = $false
|
||||
}
|
||||
|
||||
if (-not $allSigned) {
|
||||
@@ -800,7 +813,11 @@ jobs:
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
generate_release_notes: true
|
||||
# Don't regenerate/append notes on re-runs, and don't force this into the
|
||||
# "Latest" slot - leave the release body and latest marker as they are.
|
||||
generate_release_notes: false
|
||||
append_body: false
|
||||
make_latest: false
|
||||
fail_on_unmatched_files: true
|
||||
# Installers + updater payloads + manifest. .sig contents are embedded
|
||||
# in latest.json so the .sig files themselves are not uploaded.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+13
-1
@@ -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:
|
||||
|
||||
+7
-5
@@ -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() {
|
||||
|
||||
+5
@@ -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());
|
||||
|
||||
|
||||
+40
-4
@@ -43,6 +43,10 @@ public class IntegrationConfigService {
|
||||
private final OwnershipService ownership;
|
||||
private final SecretMasker secretMasker;
|
||||
private final ResourceGrantRepository grantRepository;
|
||||
// Bean-discovered extension points: features that understand a type contribute its config
|
||||
// schema and report what still references a config, without this module depending on them.
|
||||
private final List<IntegrationConfigValidator> validators;
|
||||
private final List<IntegrationConfigUsageCheck> usageChecks;
|
||||
|
||||
// ---- commands ----
|
||||
|
||||
@@ -66,13 +70,21 @@ public class IntegrationConfigService {
|
||||
? DefaultAccessPolicy.EXPLICIT_ONLY
|
||||
: request.defaultAccess());
|
||||
|
||||
// TEAM scope may omit the team id: default to the caller's own team so clients (the
|
||||
// portal) need not know it. assignOwnership still enforces admin-or-leader of that team.
|
||||
Long ownerTeamId = request.ownerTeamId();
|
||||
if (ownerTeamId == null && scope == OwnerScope.TEAM && currentUser.getTeam() != null) {
|
||||
ownerTeamId = currentUser.getTeam().getId();
|
||||
}
|
||||
ownership.assignOwnership(
|
||||
cfg,
|
||||
scope,
|
||||
request.ownerTeamId(),
|
||||
ownerTeamId,
|
||||
currentUser,
|
||||
() -> lockedServerExists(cfg.getIntegrationType()));
|
||||
cfg.setConfig(writeJson(secretMasker.sanitize(request.config())));
|
||||
Map<String, Object> config = secretMasker.sanitize(request.config());
|
||||
validateConfig(cfg.getIntegrationType(), config);
|
||||
cfg.setConfig(writeJson(config));
|
||||
return repository.save(cfg);
|
||||
}
|
||||
|
||||
@@ -101,8 +113,10 @@ public class IntegrationConfigService {
|
||||
cfg.setDefaultAccess(request.defaultAccess());
|
||||
}
|
||||
if (request.config() != null) {
|
||||
cfg.setConfig(
|
||||
writeJson(secretMasker.merge(readJson(cfg.getConfig()), request.config())));
|
||||
Map<String, Object> merged =
|
||||
secretMasker.merge(readJson(cfg.getConfig()), request.config());
|
||||
validateConfig(cfg.getIntegrationType(), merged);
|
||||
cfg.setConfig(writeJson(merged));
|
||||
}
|
||||
return repository.save(cfg);
|
||||
}
|
||||
@@ -113,6 +127,15 @@ public class IntegrationConfigService {
|
||||
if (!ownership.canManage(TYPE, cfg, currentUser)) {
|
||||
throw forbidden("You cannot manage this integration");
|
||||
}
|
||||
// Refuse to pull a connection out from under whatever still references it.
|
||||
List<String> usages =
|
||||
usageChecks.stream()
|
||||
.flatMap(check -> check.usagesOf(cfg.getId()).stream())
|
||||
.toList();
|
||||
if (!usages.isEmpty()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.CONFLICT, "Integration is in use by: " + String.join(", ", usages));
|
||||
}
|
||||
// Drop grants sharing this config so they do not dangle as dead rows.
|
||||
grantRepository.deleteByResourceTypeAndResourceId(TYPE, String.valueOf(cfg.getId()));
|
||||
repository.delete(cfg);
|
||||
@@ -188,6 +211,19 @@ public class IntegrationConfigService {
|
||||
|
||||
// ---- integration-specific glue ----
|
||||
|
||||
/** Runs every registered validator for the type; unknown types save free-form. */
|
||||
private void validateConfig(IntegrationType type, Map<String, Object> config) {
|
||||
for (IntegrationConfigValidator validator : validators) {
|
||||
if (validator.type() == type) {
|
||||
try {
|
||||
validator.validate(config == null ? Map.of() : config);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A non-admin can't create a personal config of a type an admin has locked at server scope. */
|
||||
private boolean lockedServerExists(IntegrationType type) {
|
||||
return repository.findByScope(OwnerScope.SERVER).stream()
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.proprietary.integration.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Reports what still references an integration config, so deletion can be refused instead of
|
||||
* pulling a connection out from under a live consumer. Implementations are beans discovered by
|
||||
* {@link IntegrationConfigService} (e.g. the policy subsystem reporting sources and pipelines that
|
||||
* reference a connection).
|
||||
*/
|
||||
public interface IntegrationConfigUsageCheck {
|
||||
|
||||
/** Human-readable labels of everything still using the config; empty when unreferenced. */
|
||||
List<String> usagesOf(long configId);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package stirling.software.proprietary.integration.service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
|
||||
/**
|
||||
* Validates one integration type's config map at save time. Implementations are beans discovered by
|
||||
* {@link IntegrationConfigService}, so the feature that understands a type (e.g. the policy S3
|
||||
* backend) owns its schema without the integration module depending on it. Types with no registered
|
||||
* validator save free-form.
|
||||
*/
|
||||
public interface IntegrationConfigValidator {
|
||||
|
||||
/** The type this validator understands. */
|
||||
IntegrationType type();
|
||||
|
||||
/**
|
||||
* Validates the config as it will be stored (secrets already sanitized/merged, so values are
|
||||
* real, never the redaction mask). Throws {@link IllegalArgumentException} on bad config.
|
||||
*/
|
||||
void validate(Map<String, Object> config);
|
||||
}
|
||||
+20
@@ -120,6 +120,7 @@ public class PolicyController {
|
||||
throws IOException {
|
||||
stampPolicyAudit(definition);
|
||||
requireRunnable(definition);
|
||||
validateAdHocOutput(definition);
|
||||
PolicyInputs inputs = toInputs(files);
|
||||
PolicyRunHandle handle =
|
||||
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP);
|
||||
@@ -140,6 +141,7 @@ public class PolicyController {
|
||||
throws IOException {
|
||||
stampPolicyAudit(definition);
|
||||
requireRunnable(definition);
|
||||
validateAdHocOutput(definition);
|
||||
PolicyInputs inputs = toInputs(files);
|
||||
|
||||
SseEmitter emitter =
|
||||
@@ -530,6 +532,24 @@ public class PolicyController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization-check an ad-hoc run's output while the caller's principal is present (this
|
||||
* request thread). The worker thread that later delivers carries no security context, so an S3
|
||||
* output's connection-access check would be skipped there; without this gate a caller could
|
||||
* reference another tenant's connection by id and write to it (confused deputy). Stored
|
||||
* policies are covered by save-time {@link PolicyValidator#validate} instead.
|
||||
*/
|
||||
private void validateAdHocOutput(PipelineDefinition definition) {
|
||||
if (definition.output() == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
policyValidator.validateOutput(definition.output());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ad-hoc runs (AI / one-off pipelines) are still editor activity, so their supplied documents
|
||||
* feed the same virtual editor source as stored editor policies, counted against the caller's
|
||||
|
||||
+13
-1
@@ -52,7 +52,19 @@ public class PolicyValidator {
|
||||
InputSpec spec = source.toInputSpec();
|
||||
inputSourceFor(spec).validate(spec);
|
||||
}
|
||||
outputSinkFor(policy.output()).validate(policy.output());
|
||||
validateOutput(policy.output());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an output spec against its sink. Must be called on a request thread (caller's
|
||||
* principal present) so an S3 output's connection is authorization-checked against the caller -
|
||||
* ad-hoc runs are never persisted and so never hit {@link #validate(Policy)}, and the worker
|
||||
* thread that later delivers has no principal, so this is their only access gate.
|
||||
*
|
||||
* @throws IllegalArgumentException if the type is unknown or the config is invalid/inaccessible
|
||||
*/
|
||||
public void validateOutput(OutputSpec output) {
|
||||
outputSinkFor(output).validate(output);
|
||||
}
|
||||
|
||||
private PolicyTrigger triggerFor(TriggerConfig config) {
|
||||
|
||||
+14
-13
@@ -18,6 +18,7 @@ import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.s3.S3Config;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionResolver;
|
||||
import stirling.software.proprietary.policy.s3.S3Identities;
|
||||
|
||||
import software.amazon.awssdk.core.exception.SdkException;
|
||||
@@ -36,15 +37,14 @@ import software.amazon.awssdk.services.s3.model.S3Object;
|
||||
* Reads input files from an Amazon S3 (or S3-compatible) bucket; each listed object is its own unit
|
||||
* of work, claimed through the {@link ResolveContext} ledger and tracked in place. Identity and
|
||||
* version gate come from {@link S3Identities}, so the steady-state sweep never downloads content.
|
||||
* Options (see {@link S3Config}): "bucket" (required), "region" (default us-east-1), "prefix" (only
|
||||
* keys starting with it are read), "endpoint" (S3-compatible stores such as MinIO; path-style
|
||||
* addressing is used automatically), "accessKeyId" and "secretAccessKey" (required; requests are
|
||||
* never signed with the server's own AWS identity), and "mode" which is "consume" (default: a
|
||||
* processed object is deleted once every policy that claimed it has settled successfully and it is
|
||||
* still the version that ran; failures stay in place and are not retried until they change) or
|
||||
* "snapshot" (stateless, every run sees the full set). Keys ending in "/" (folder placeholders) and
|
||||
* keys with a dot-prefixed path segment are never picked up, mirroring the folder source's
|
||||
* hidden-file rule.
|
||||
* Options: "connectionId" references the stored S3 connection (an {@code IntegrationConfig} owning
|
||||
* bucket, region, endpoint, and credentials - resolved by {@link S3ConnectionResolver}); "prefix"
|
||||
* (only keys starting with it are read) and "mode" are per-source, where mode is "consume"
|
||||
* (default: a processed object is deleted once every policy that claimed it has settled
|
||||
* successfully and it is still the version that ran; failures stay in place and are not retried
|
||||
* until they change) or "snapshot" (stateless, every run sees the full set). Keys ending in "/"
|
||||
* (folder placeholders) and keys with a dot-prefixed path segment are never picked up, mirroring
|
||||
* the folder source's hidden-file rule.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -55,6 +55,7 @@ public class S3InputSource implements InputSource {
|
||||
private static final String TYPE = "s3";
|
||||
|
||||
private final S3ConnectionPool connectionPool;
|
||||
private final S3ConnectionResolver connectionResolver;
|
||||
|
||||
@Override
|
||||
public String type() {
|
||||
@@ -67,12 +68,12 @@ public class S3InputSource implements InputSource {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails fast at save time: bad config shape, a private endpoint without the operator opt-in, or
|
||||
* a bucket the supplied credentials cannot list.
|
||||
* Fails fast at save time: an unknown/disabled/unusable connection, bad config shape, a private
|
||||
* endpoint without the operator opt-in, or a bucket the connection cannot list.
|
||||
*/
|
||||
@Override
|
||||
public void validate(InputSpec spec) {
|
||||
S3Config config = S3Config.from(spec.options());
|
||||
S3Config config = connectionResolver.resolve(spec.options());
|
||||
try {
|
||||
connectionPool.clientFor(config).listObjectsV2(listRequest(config).maxKeys(1).build());
|
||||
} catch (SdkException e) {
|
||||
@@ -89,7 +90,7 @@ public class S3InputSource implements InputSource {
|
||||
|
||||
@Override
|
||||
public List<ResolvedInput> resolve(InputSpec spec, ResolveContext ctx) throws IOException {
|
||||
S3Config config = S3Config.from(spec.options());
|
||||
S3Config config = connectionResolver.resolve(spec.options());
|
||||
S3Client client = connectionPool.clientFor(config);
|
||||
// A listing failure propagates so the sweep reads it as "could not list" (which vetoes
|
||||
// presence cleanup), never as "verifiably no objects".
|
||||
|
||||
+7
-5
@@ -27,6 +27,7 @@ import stirling.software.proprietary.policy.ledger.ProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3Config;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionResolver;
|
||||
import stirling.software.proprietary.policy.s3.S3Identities;
|
||||
|
||||
import software.amazon.awssdk.core.exception.SdkException;
|
||||
@@ -59,6 +60,7 @@ public class S3OutputSink implements PolicyOutputSink {
|
||||
private static final String TYPE = "s3";
|
||||
|
||||
private final S3ConnectionPool connectionPool;
|
||||
private final S3ConnectionResolver connectionResolver;
|
||||
private final ProcessedLedger processedLedger;
|
||||
|
||||
@Override
|
||||
@@ -72,19 +74,19 @@ public class S3OutputSink implements PolicyOutputSink {
|
||||
}
|
||||
|
||||
/**
|
||||
* Config shape and endpoint guard only - no network probe, since write-only credentials
|
||||
* (s3:PutObject without s3:ListBucket) are a legitimate setup for an output bucket and a
|
||||
* listing probe would wrongly reject them.
|
||||
* Connection resolution (including the saving user's right to use it) and endpoint guard only -
|
||||
* no network probe, since write-only credentials (s3:PutObject without s3:ListBucket) are a
|
||||
* legitimate setup for an output bucket and a listing probe would wrongly reject them.
|
||||
*/
|
||||
@Override
|
||||
public void validate(OutputSpec spec) {
|
||||
connectionPool.clientFor(S3Config.from(spec.options()));
|
||||
connectionPool.clientFor(connectionResolver.resolve(spec.options()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ResultFile> deliver(
|
||||
OutputDelivery delivery, List<Resource> outputs, OutputSpec spec) throws IOException {
|
||||
S3Config config = S3Config.from(spec.options());
|
||||
S3Config config = connectionResolver.resolve(spec.options());
|
||||
S3Client client = connectionPool.clientFor(config);
|
||||
|
||||
List<ResultFile> results = new ArrayList<>();
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.integration.model.IntegrationConfig;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* One-time, idempotent extraction of legacy embedded S3 credentials into stored connections:
|
||||
* sources and policy outputs written before connections shipped carry bucket/credentials in their
|
||||
* own options; this rewrites each to reference a (deduplicated) S3 {@link IntegrationConfig} and
|
||||
* keeps only per-use options (prefix, mode). MUST be programmatic - the option JSON is encrypted at
|
||||
* the application layer, so no SQL migration can read it.
|
||||
*
|
||||
* <p>Idempotent by construction: rewritten rows no longer embed credentials, so re-runs find
|
||||
* nothing to do. Connections are deduplicated against both this run's extractions and existing S3
|
||||
* connections; a concurrent multi-node boot can at worst create a redundant connection row, never
|
||||
* corrupt a source. Ownership follows the owning row: team-scoped when the source/policy has a
|
||||
* team, server-scoped otherwise (single-operator self-hosted).
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class EmbeddedS3CredentialMigration {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
private static final List<String> CONNECTION_OPTIONS =
|
||||
List.of("bucket", "region", "endpoint", "accessKeyId", "secretAccessKey");
|
||||
// Field separator for the dedup key: a unit-separator control char that cannot appear in a
|
||||
// bucket/region/endpoint/credential, so distinct field sets can never collide.
|
||||
private static final char DELIMITER = '\u001f';
|
||||
|
||||
private final SourceStore sourceStore;
|
||||
private final PolicyStore policyStore;
|
||||
private final IntegrationConfigRepository connections;
|
||||
private final TeamRepository teamRepository;
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@Transactional
|
||||
public void migrate() {
|
||||
Map<String, IntegrationConfig> byCredentialKey = indexExistingConnections();
|
||||
int migrated = 0;
|
||||
for (Source source : sourceStore.all()) {
|
||||
if (!"s3".equals(source.type()) || !embedsCredentials(source.options())) {
|
||||
continue;
|
||||
}
|
||||
IntegrationConfig connection =
|
||||
connectionFor(source.options(), source.teamId(), byCredentialKey);
|
||||
sourceStore.save(withOptions(source, referencing(connection, source.options(), true)));
|
||||
migrated++;
|
||||
}
|
||||
for (Policy policy : policyStore.all()) {
|
||||
OutputSpec output = policy.output();
|
||||
if (!"s3".equals(output.type()) || !embedsCredentials(output.options())) {
|
||||
continue;
|
||||
}
|
||||
IntegrationConfig connection =
|
||||
connectionFor(output.options(), policy.teamId(), byCredentialKey);
|
||||
policyStore.save(
|
||||
withOutput(
|
||||
policy,
|
||||
new OutputSpec(
|
||||
output.type(),
|
||||
referencing(connection, output.options(), false))));
|
||||
migrated++;
|
||||
}
|
||||
if (migrated > 0) {
|
||||
log.info("Extracted embedded S3 credentials from {} row(s) into connections", migrated);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean embedsCredentials(Map<String, Object> options) {
|
||||
return options.get("accessKeyId") != null;
|
||||
}
|
||||
|
||||
/** Reuses an existing connection with identical coordinates+credentials, else creates one. */
|
||||
private IntegrationConfig connectionFor(
|
||||
Map<String, Object> options, Long teamId, Map<String, IntegrationConfig> byKey) {
|
||||
String key = credentialKey(options);
|
||||
IntegrationConfig existing = byKey.get(key);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
IntegrationConfig connection = new IntegrationConfig();
|
||||
connection.setIntegrationType(IntegrationType.S3);
|
||||
connection.setName(connectionName(options, byKey));
|
||||
connection.setEnabled(true);
|
||||
connection.setLocked(false);
|
||||
connection.setDefaultAccess(DefaultAccessPolicy.EXPLICIT_ONLY);
|
||||
Team team = teamId == null ? null : teamRepository.findById(teamId).orElse(null);
|
||||
if (team != null) {
|
||||
connection.setScope(OwnerScope.TEAM);
|
||||
connection.setOwnerTeam(team);
|
||||
} else {
|
||||
// No team (teamless self-hosted, or a source whose team was since deleted): server
|
||||
// scope, i.e. admin-owned. An orphaned-team source's non-admin editor would then need
|
||||
// an admin to re-share the connection - acceptable for the narrow orphaned case.
|
||||
connection.setScope(OwnerScope.SERVER);
|
||||
}
|
||||
Map<String, Object> config = new LinkedHashMap<>();
|
||||
for (String option : CONNECTION_OPTIONS) {
|
||||
Object value = options.get(option);
|
||||
if (value != null && !value.toString().isBlank()) {
|
||||
config.put(option, value);
|
||||
}
|
||||
}
|
||||
connection.setConfig(OBJECT_MAPPER.writeValueAsString(config));
|
||||
IntegrationConfig saved = connections.save(connection);
|
||||
byKey.put(key, saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** The rewritten options: the connection reference plus per-use settings only. */
|
||||
private static Map<String, Object> referencing(
|
||||
IntegrationConfig connection, Map<String, Object> legacy, boolean keepMode) {
|
||||
Map<String, Object> options = new LinkedHashMap<>();
|
||||
options.put(S3ConnectionResolver.CONNECTION_ID_OPTION, connection.getId());
|
||||
Object prefix = legacy.get("prefix");
|
||||
if (prefix != null && !prefix.toString().isBlank()) {
|
||||
options.put("prefix", prefix);
|
||||
}
|
||||
Object mode = legacy.get("mode");
|
||||
if (keepMode && mode != null && !mode.toString().isBlank()) {
|
||||
options.put("mode", mode);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private Map<String, IntegrationConfig> indexExistingConnections() {
|
||||
Map<String, IntegrationConfig> byKey = new LinkedHashMap<>();
|
||||
for (IntegrationConfig connection : connections.findAll()) {
|
||||
if (connection.getIntegrationType() != IntegrationType.S3) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> config =
|
||||
OBJECT_MAPPER.readValue(connection.getConfig(), Map.class);
|
||||
byKey.putIfAbsent(credentialKey(config), connection);
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Skipping unreadable S3 connection {} while indexing: {}",
|
||||
connection.getId(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
return byKey;
|
||||
}
|
||||
|
||||
private static String credentialKey(Map<String, Object> options) {
|
||||
StringBuilder key = new StringBuilder();
|
||||
for (String option : CONNECTION_OPTIONS) {
|
||||
Object value = options.get(option);
|
||||
key.append(value == null ? "" : value.toString().trim()).append(DELIMITER);
|
||||
}
|
||||
return key.toString();
|
||||
}
|
||||
|
||||
private static String connectionName(
|
||||
Map<String, Object> options, Map<String, IntegrationConfig> byKey) {
|
||||
String base = "S3: " + options.getOrDefault("bucket", "bucket");
|
||||
long sameName = byKey.values().stream().filter(c -> c.getName().startsWith(base)).count();
|
||||
return sameName == 0 ? base : base + " (" + (sameName + 1) + ")";
|
||||
}
|
||||
|
||||
private static Source withOptions(Source source, Map<String, Object> options) {
|
||||
return new Source(
|
||||
source.id(),
|
||||
source.name(),
|
||||
source.type(),
|
||||
options,
|
||||
source.enabled(),
|
||||
source.owner(),
|
||||
source.teamId());
|
||||
}
|
||||
|
||||
private static Policy withOutput(Policy policy, OutputSpec output) {
|
||||
return new Policy(
|
||||
policy.id(),
|
||||
policy.name(),
|
||||
policy.owner(),
|
||||
policy.enabled(),
|
||||
policy.trigger(),
|
||||
policy.sourceIds(),
|
||||
policy.steps(),
|
||||
output,
|
||||
policy.teamId());
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.integration.service.IntegrationConfigUsageCheck;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
|
||||
/**
|
||||
* Reports the policy sources and pipeline outputs referencing an S3 connection, so the connection
|
||||
* cannot be deleted out from under them (mirrors {@code SourceController}'s referenced-source
|
||||
* delete guard). Scans in memory - fine at admin-dashboard scale, always consistent with the live
|
||||
* stores.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class PolicyS3ConnectionUsageCheck implements IntegrationConfigUsageCheck {
|
||||
|
||||
private final SourceStore sourceStore;
|
||||
private final PolicyStore policyStore;
|
||||
|
||||
@Override
|
||||
public List<String> usagesOf(long configId) {
|
||||
List<String> usages = new ArrayList<>();
|
||||
for (Source source : sourceStore.all()) {
|
||||
if (references(source.options(), configId)) {
|
||||
usages.add("source '" + source.name() + "'");
|
||||
}
|
||||
}
|
||||
for (Policy policy : policyStore.all()) {
|
||||
if (references(policy.output().options(), configId)) {
|
||||
usages.add("pipeline '" + policy.name() + "'");
|
||||
}
|
||||
}
|
||||
return usages;
|
||||
}
|
||||
|
||||
private static boolean references(Map<String, Object> options, long configId) {
|
||||
try {
|
||||
Long reference = S3ConnectionResolver.connectionId(options);
|
||||
return reference != null && reference == configId;
|
||||
} catch (IllegalArgumentException unparseable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import java.net.URISyntaxException;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Connection settings shared by the S3 input source and output sink, parsed from a spec's options
|
||||
* map. Credentials are required: there is deliberately no fallback to the server's own AWS
|
||||
* credential chain, so user-supplied config can never borrow the host's identity. {@code snapshot}
|
||||
* is input-only and ignored by the sink.
|
||||
* The fully resolved connection settings the S3 input source and output sink run with - normally
|
||||
* produced by {@link S3ConnectionResolver} merging a stored connection (bucket, region, endpoint,
|
||||
* credentials) with per-use options (prefix, mode), or parsed directly from legacy options that
|
||||
* still embed credentials. Credentials are required: there is deliberately no fallback to the
|
||||
* server's own AWS credential chain, so user-supplied config can never borrow the host's identity.
|
||||
* {@code snapshot} is input-only and ignored by the sink.
|
||||
*/
|
||||
public record S3Config(
|
||||
String bucket,
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
import stirling.software.proprietary.access.service.OwnershipService;
|
||||
import stirling.software.proprietary.integration.model.IntegrationConfig;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Turns a source's or output's options into a full {@link S3Config} by dereferencing its {@code
|
||||
* connectionId} to a stored S3 {@link IntegrationConfig} (the connection owns bucket, region,
|
||||
* endpoint, and credentials; the options own per-use settings such as prefix and mode). Options
|
||||
* with no {@code connectionId} fall back to legacy embedded credentials, so rows written before
|
||||
* connections shipped keep working until {@link EmbeddedS3CredentialMigration} rewrites them.
|
||||
*
|
||||
* <p>When an authenticated caller is present (save-time validation), they must be allowed to use
|
||||
* the connection. Background sweeps and deliveries run with no caller and skip that check: the
|
||||
* referencing source or policy was access-checked when it was saved.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class S3ConnectionResolver {
|
||||
|
||||
static final String CONNECTION_ID_OPTION = "connectionId";
|
||||
private static final String PREFIX_OPTION = "prefix";
|
||||
private static final String MODE_OPTION = "mode";
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final IntegrationConfigRepository connections;
|
||||
private final OwnershipService ownership;
|
||||
private final UserService userService;
|
||||
|
||||
public S3Config resolve(Map<String, Object> options) {
|
||||
Long connectionId = connectionId(options);
|
||||
if (connectionId == null) {
|
||||
// Legacy embedded credentials, pending migration.
|
||||
return S3Config.from(options);
|
||||
}
|
||||
IntegrationConfig connection =
|
||||
connections
|
||||
.findById(connectionId)
|
||||
.filter(cfg -> cfg.getIntegrationType() == IntegrationType.S3)
|
||||
.filter(this::usableByCurrentUser)
|
||||
// Existence and access collapse into one error: a caller must not be able
|
||||
// to tell "no such connection" from "someone else's connection" and
|
||||
// enumerate ids. The id/name are never echoed.
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalArgumentException(
|
||||
"unknown or inaccessible s3 connection"));
|
||||
if (!connection.isEnabled()) {
|
||||
throw new IllegalArgumentException("s3 connection is disabled");
|
||||
}
|
||||
Map<String, Object> merged = new LinkedHashMap<>(connectionConfig(connection));
|
||||
copyPerUseOption(options, merged, PREFIX_OPTION);
|
||||
copyPerUseOption(options, merged, MODE_OPTION);
|
||||
return S3Config.from(merged);
|
||||
}
|
||||
|
||||
/** The {@code connectionId} option as a long, or null when the options are legacy-embedded. */
|
||||
static Long connectionId(Map<String, Object> options) {
|
||||
Object reference = options.get(CONNECTION_ID_OPTION);
|
||||
if (reference == null || (reference instanceof String s && s.isBlank())) {
|
||||
return null;
|
||||
}
|
||||
if (reference instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(reference.toString().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"s3 'connectionId' is not a valid connection reference: " + reference);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current caller may use this connection. With no principal - a background sweep or
|
||||
* delivery on a worker thread that carries no {@code SecurityContext} - access is treated as
|
||||
* already established: stored policies are validated with the caller present at save time, and
|
||||
* ad-hoc runs are validated on the request thread before dispatch (see {@code
|
||||
* PolicyValidator#validateOutput}). A missing principal must therefore never be the ONLY thing
|
||||
* standing between a caller and a connection, or the check becomes a confused deputy.
|
||||
*/
|
||||
private boolean usableByCurrentUser(IntegrationConfig connection) {
|
||||
User user = currentUser();
|
||||
return user == null || ownership.canUse(ResourceType.INTEGRATION_CONFIG, connection, user);
|
||||
}
|
||||
|
||||
// Mirrors ResourceAccessSecurity's principal resolution; null when unauthenticated.
|
||||
private User currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
Object principal = auth.getPrincipal();
|
||||
if (principal instanceof User user) {
|
||||
return user;
|
||||
}
|
||||
if (principal instanceof UserDetails userDetails) {
|
||||
return userService.findByUsername(userDetails.getUsername()).orElse(null);
|
||||
}
|
||||
if (principal instanceof String username && !"anonymousUser".equals(username)) {
|
||||
return userService.findByUsername(username).orElse(null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Map<String, Object> connectionConfig(IntegrationConfig connection) {
|
||||
String json = connection.getConfig();
|
||||
if (json == null || json.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
return OBJECT_MAPPER.readValue(
|
||||
json, new TypeReference<LinkedHashMap<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException(
|
||||
"s3 connection '" + connection.getName() + "' has unreadable config", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyPerUseOption(
|
||||
Map<String, Object> options, Map<String, Object> merged, String key) {
|
||||
Object value = options.get(key);
|
||||
if (value != null && !value.toString().isBlank()) {
|
||||
merged.put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.cluster.s3.S3Clients;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
import stirling.software.proprietary.integration.service.IntegrationConfigValidator;
|
||||
|
||||
/**
|
||||
* The S3 connection schema, enforced when an S3 {@link IntegrationType} config is saved: bucket and
|
||||
* credentials required, endpoint an http(s) URL that must not reach private addresses without the
|
||||
* operator opt-in - the same rules {@link S3ConnectionPool} enforces before signing, moved to save
|
||||
* time so a bad connection fails in the form rather than in a sweep.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class S3IntegrationValidator implements IntegrationConfigValidator {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Override
|
||||
public IntegrationType type() {
|
||||
return IntegrationType.S3;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(Map<String, Object> config) {
|
||||
S3Config parsed = S3Config.from(config);
|
||||
if (parsed.endpoint() == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
S3Clients.validateEndpointHost(
|
||||
URI.create(parsed.endpoint()),
|
||||
applicationProperties.getPolicies().isAllowPrivateS3Endpoints(),
|
||||
"S3 connection endpoint",
|
||||
"set policies.allowPrivateS3Endpoints=true to opt in (e.g. for a local"
|
||||
+ " MinIO).");
|
||||
} catch (IllegalStateException e) {
|
||||
throw new IllegalArgumentException(e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
-2
@@ -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 */
|
||||
|
||||
@@ -103,7 +103,6 @@ public class User implements UserDetails, Serializable {
|
||||
|
||||
@ElementCollection
|
||||
@MapKeyColumn(name = "setting_key")
|
||||
@Lob
|
||||
@Column(name = "setting_value", columnDefinition = "text")
|
||||
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
|
||||
@JsonIgnore
|
||||
|
||||
+181
@@ -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;
|
||||
}
|
||||
}
|
||||
+58
@@ -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");
|
||||
}
|
||||
}
|
||||
+3
@@ -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,
|
||||
|
||||
+53
-2
@@ -13,9 +13,9 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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.http.HttpStatus;
|
||||
@@ -52,7 +52,58 @@ class IntegrationConfigServiceTest {
|
||||
@Mock
|
||||
private stirling.software.proprietary.access.repository.ResourceGrantRepository grantRepository;
|
||||
|
||||
@InjectMocks private IntegrationConfigService service;
|
||||
@Mock private IntegrationConfigValidator validator;
|
||||
@Mock private IntegrationConfigUsageCheck usageCheck;
|
||||
|
||||
private IntegrationConfigService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service =
|
||||
new IntegrationConfigService(
|
||||
repository,
|
||||
ownership,
|
||||
secretMasker,
|
||||
grantRepository,
|
||||
List.of(validator),
|
||||
List.of(usageCheck));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRejectsAConfigItsTypeValidatorRefuses() {
|
||||
when(secretMasker.sanitize(any())).thenReturn(Map.of());
|
||||
when(validator.type()).thenReturn(IntegrationType.API);
|
||||
org.mockito.Mockito.doThrow(new IllegalArgumentException("api config needs a 'url'"))
|
||||
.when(validator)
|
||||
.validate(any());
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
service.create(
|
||||
request(IntegrationType.API, OwnerScope.USER, null),
|
||||
user(7)))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRefusedWhileAnythingStillReferencesTheConfig() {
|
||||
IntegrationConfig cfg = config(9L);
|
||||
when(repository.findById(9L)).thenReturn(Optional.of(cfg));
|
||||
when(ownership.canManage(any(), eq(cfg), any())).thenReturn(true);
|
||||
when(usageCheck.usagesOf(9L)).thenReturn(List.of("source 'Claims intake'"));
|
||||
|
||||
assertThatThrownBy(() -> service.delete(9L, user(7)))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.CONFLICT));
|
||||
verify(repository, org.mockito.Mockito.never()).delete(any(IntegrationConfig.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDelegatesOwnershipAndSanitizesConfig() {
|
||||
|
||||
+24
@@ -4,6 +4,7 @@ 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.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -194,6 +195,29 @@ class PolicyControllerTest {
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects an ad-hoc output the caller cannot use, on the request thread")
|
||||
void rejectsUnauthorizedAdHocOutput() {
|
||||
// The confused-deputy guard: an S3 output referencing a connection the caller may not
|
||||
// use is validated here (principal present) and refused before any worker dispatch.
|
||||
PipelineDefinition definition =
|
||||
new PipelineDefinition(
|
||||
"pipe",
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)),
|
||||
new OutputSpec("s3", Map.of("connectionId", 999)));
|
||||
doThrow(new IllegalArgumentException("unknown or inaccessible s3 connection"))
|
||||
.when(policyValidator)
|
||||
.validateOutput(any());
|
||||
|
||||
assertThatThrownBy(() -> controller.run(definition, new PolicyRunFiles()))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST));
|
||||
verify(policyRunner, never()).runAdHoc(any(), any(), any());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
||||
+22
@@ -82,6 +82,28 @@ class PolicyValidatorTest {
|
||||
assertTrue(ex.getMessage().contains("schedule"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateOutputDelegatesToTheSink() {
|
||||
when(outputSink.supports(any())).thenReturn(true);
|
||||
OutputSpec output = new OutputSpec("s3", Map.of("connectionId", 1));
|
||||
|
||||
validator.validateOutput(output);
|
||||
|
||||
verify(outputSink).validate(output);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateOutputSurfacesAnInaccessibleConnection() {
|
||||
when(outputSink.supports(any())).thenReturn(true);
|
||||
doThrow(new IllegalArgumentException("unknown or inaccessible s3 connection"))
|
||||
.when(outputSink)
|
||||
.validate(any());
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> validator.validateOutput(new OutputSpec("s3", Map.of("connectionId", 1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnUnknownTriggerType() {
|
||||
when(trigger.type()).thenReturn("schedule");
|
||||
|
||||
+7
-2
@@ -23,6 +23,7 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3TestConnections;
|
||||
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
@@ -82,7 +83,9 @@ class S3InputSourceMinioTest {
|
||||
// The MinIO endpoint resolves to loopback, so the operator opt-in must be on.
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
properties.getPolicies().setAllowPrivateS3Endpoints(true);
|
||||
source = new S3InputSource(new S3ConnectionPool(properties));
|
||||
source =
|
||||
new S3InputSource(
|
||||
new S3ConnectionPool(properties), S3TestConnections.legacyResolver());
|
||||
ledger = new InProcessProcessedLedger();
|
||||
ctx = new RecordingContext();
|
||||
}
|
||||
@@ -161,7 +164,9 @@ class S3InputSourceMinioTest {
|
||||
@Test
|
||||
void aPrivateEndpointIsRejectedWithoutTheOperatorOptIn() {
|
||||
S3InputSource guarded =
|
||||
new S3InputSource(new S3ConnectionPool(new ApplicationProperties()));
|
||||
new S3InputSource(
|
||||
new S3ConnectionPool(new ApplicationProperties()),
|
||||
S3TestConnections.legacyResolver());
|
||||
|
||||
assertThatThrownBy(() -> guarded.validate(spec(Map.of())))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
|
||||
+3
-1
@@ -29,6 +29,7 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3TestConnections;
|
||||
|
||||
import software.amazon.awssdk.core.ResponseInputStream;
|
||||
import software.amazon.awssdk.core.exception.SdkClientException;
|
||||
@@ -64,7 +65,8 @@ class S3InputSourceTest {
|
||||
void setUp() {
|
||||
source =
|
||||
new S3InputSource(
|
||||
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client));
|
||||
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client),
|
||||
S3TestConnections.legacyResolver());
|
||||
ledger = new InProcessProcessedLedger();
|
||||
ctx = new RecordingContext();
|
||||
}
|
||||
|
||||
+3
-2
@@ -28,6 +28,7 @@ import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3TestConnections;
|
||||
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
@@ -90,8 +91,8 @@ class S3OutputSinkMinioTest {
|
||||
properties.getPolicies().setAllowPrivateS3Endpoints(true);
|
||||
S3ConnectionPool pool = new S3ConnectionPool(properties);
|
||||
ledger = new InProcessProcessedLedger();
|
||||
sink = new S3OutputSink(pool, ledger);
|
||||
source = new S3InputSource(pool);
|
||||
sink = new S3OutputSink(pool, S3TestConnections.legacyResolver(), ledger);
|
||||
source = new S3InputSource(pool, S3TestConnections.legacyResolver());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+2
@@ -32,6 +32,7 @@ import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.ledger.ProcessedFileStatus;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3TestConnections;
|
||||
|
||||
import software.amazon.awssdk.awscore.exception.AwsServiceException;
|
||||
import software.amazon.awssdk.core.exception.SdkClientException;
|
||||
@@ -66,6 +67,7 @@ class S3OutputSinkTest {
|
||||
sink =
|
||||
new S3OutputSink(
|
||||
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client),
|
||||
S3TestConnections.legacyResolver(),
|
||||
ledger);
|
||||
}
|
||||
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.integration.model.IntegrationConfig;
|
||||
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.source.InProcessSourceStore;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
|
||||
/**
|
||||
* Tests for {@link EmbeddedS3CredentialMigration}: legacy embedded credentials become deduplicated
|
||||
* team-scoped connections, rewritten rows keep only per-use options, and re-runs are no-ops.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class EmbeddedS3CredentialMigrationTest {
|
||||
|
||||
@Mock private IntegrationConfigRepository connections;
|
||||
@Mock private TeamRepository teamRepository;
|
||||
|
||||
private final InProcessSourceStore sourceStore = new InProcessSourceStore();
|
||||
private final InProcessPolicyStore policyStore = new InProcessPolicyStore();
|
||||
private EmbeddedS3CredentialMigration migration;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
migration =
|
||||
new EmbeddedS3CredentialMigration(
|
||||
sourceStore, policyStore, connections, teamRepository);
|
||||
AtomicLong ids = new AtomicLong(100);
|
||||
// Lenient: the nothing-to-migrate cases never create a connection.
|
||||
lenient().when(connections.findAll()).thenReturn(List.of());
|
||||
lenient()
|
||||
.when(connections.save(any()))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
IntegrationConfig saved = invocation.getArgument(0);
|
||||
if (saved.getId() == null) {
|
||||
saved.setId(ids.incrementAndGet());
|
||||
}
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractsSharedCredentialsIntoOneTeamScopedConnection() {
|
||||
Team team = new Team();
|
||||
team.setId(7L);
|
||||
when(teamRepository.findById(7L)).thenReturn(Optional.of(team));
|
||||
Source source =
|
||||
sourceStore.save(
|
||||
new Source(
|
||||
null,
|
||||
"Claims intake",
|
||||
"s3",
|
||||
Map.of(
|
||||
"bucket", "inbox",
|
||||
"prefix", "incoming/",
|
||||
"mode", "snapshot",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh"),
|
||||
true,
|
||||
"alice",
|
||||
7L));
|
||||
Policy policy =
|
||||
policyStore.save(
|
||||
new Policy(
|
||||
null,
|
||||
"Rotate",
|
||||
"alice",
|
||||
true,
|
||||
null,
|
||||
List.of(source.id()),
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
|
||||
new OutputSpec(
|
||||
"s3",
|
||||
Map.of(
|
||||
"bucket", "inbox",
|
||||
"prefix", "processed/",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh")),
|
||||
7L));
|
||||
|
||||
migration.migrate();
|
||||
|
||||
// Same bucket + credentials on both rows: exactly one connection extracted.
|
||||
verify(connections, times(1)).save(any());
|
||||
Map<String, Object> sourceOptions = sourceStore.get(source.id()).orElseThrow().options();
|
||||
assertEquals(101L, sourceOptions.get("connectionId"));
|
||||
assertEquals("incoming/", sourceOptions.get("prefix"));
|
||||
assertEquals("snapshot", sourceOptions.get("mode"));
|
||||
assertNull(sourceOptions.get("accessKeyId"));
|
||||
assertNull(sourceOptions.get("secretAccessKey"));
|
||||
assertNull(sourceOptions.get("bucket"));
|
||||
|
||||
Map<String, Object> outputOptions =
|
||||
policyStore.get(policy.id()).orElseThrow().output().options();
|
||||
assertEquals(101L, outputOptions.get("connectionId"));
|
||||
assertEquals("processed/", outputOptions.get("prefix"));
|
||||
assertNull(outputOptions.get("secretAccessKey"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectionOwnershipFollowsTheSourceTeam() {
|
||||
Team team = new Team();
|
||||
team.setId(7L);
|
||||
when(teamRepository.findById(7L)).thenReturn(Optional.of(team));
|
||||
sourceStore.save(s3Source("teamed", 7L));
|
||||
|
||||
migration.migrate();
|
||||
|
||||
verify(connections)
|
||||
.save(
|
||||
org.mockito.ArgumentMatchers.argThat(
|
||||
connection ->
|
||||
connection.getScope() == OwnerScope.TEAM
|
||||
&& connection.getOwnerTeam() == team));
|
||||
}
|
||||
|
||||
@Test
|
||||
void teamlessRowsBecomeServerScopedConnections() {
|
||||
sourceStore.save(s3Source("solo", null));
|
||||
|
||||
migration.migrate();
|
||||
|
||||
verify(connections)
|
||||
.save(
|
||||
org.mockito.ArgumentMatchers.argThat(
|
||||
connection -> connection.getScope() == OwnerScope.SERVER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSecondRunFindsNothingToDo() {
|
||||
sourceStore.save(s3Source("once", null));
|
||||
|
||||
migration.migrate();
|
||||
migration.migrate();
|
||||
|
||||
// One connection from the first run; the rewritten source no longer embeds credentials.
|
||||
verify(connections, times(1)).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonS3AndAlreadyMigratedRowsAreUntouched() {
|
||||
Source folder =
|
||||
sourceStore.save(
|
||||
new Source(
|
||||
null,
|
||||
"Folder",
|
||||
"folder",
|
||||
Map.of("directory", "/in"),
|
||||
true,
|
||||
"alice",
|
||||
null));
|
||||
Source migrated =
|
||||
sourceStore.save(
|
||||
new Source(
|
||||
null,
|
||||
"Done already",
|
||||
"s3",
|
||||
Map.of("connectionId", 55L, "prefix", "in/"),
|
||||
true,
|
||||
"alice",
|
||||
null));
|
||||
|
||||
migration.migrate();
|
||||
|
||||
verify(connections, times(0)).save(any());
|
||||
assertEquals(
|
||||
Map.of("directory", "/in"), sourceStore.get(folder.id()).orElseThrow().options());
|
||||
assertEquals(
|
||||
Map.of("connectionId", 55L, "prefix", "in/"),
|
||||
sourceStore.get(migrated.id()).orElseThrow().options());
|
||||
}
|
||||
|
||||
private static Source s3Source(String name, Long teamId) {
|
||||
return new Source(
|
||||
null,
|
||||
name,
|
||||
"s3",
|
||||
Map.of(
|
||||
"bucket", "inbox",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh"),
|
||||
true,
|
||||
"alice",
|
||||
teamId);
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.source.InProcessSourceStore;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
|
||||
|
||||
/** Tests for {@link PolicyS3ConnectionUsageCheck}'s reference scan across sources and outputs. */
|
||||
class PolicyS3ConnectionUsageCheckTest {
|
||||
|
||||
private final InProcessSourceStore sourceStore = new InProcessSourceStore();
|
||||
private final InProcessPolicyStore policyStore = new InProcessPolicyStore();
|
||||
private final PolicyS3ConnectionUsageCheck check =
|
||||
new PolicyS3ConnectionUsageCheck(sourceStore, policyStore);
|
||||
|
||||
@Test
|
||||
void reportsSourcesAndOutputsReferencingTheConnection() {
|
||||
sourceStore.save(
|
||||
new Source(
|
||||
null,
|
||||
"Claims intake",
|
||||
"s3",
|
||||
Map.of("connectionId", 5L, "prefix", "in/"),
|
||||
true,
|
||||
"alice",
|
||||
null));
|
||||
policyStore.save(
|
||||
new Policy(
|
||||
null,
|
||||
"Rotate",
|
||||
"alice",
|
||||
true,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
|
||||
new OutputSpec("s3", Map.of("connectionId", "5")),
|
||||
null));
|
||||
|
||||
assertThat(check.usagesOf(5))
|
||||
.containsExactlyInAnyOrder("source 'Claims intake'", "pipeline 'Rotate'");
|
||||
assertThat(check.usagesOf(6)).isEmpty();
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import stirling.software.proprietary.access.service.OwnershipService;
|
||||
import stirling.software.proprietary.integration.model.IntegrationConfig;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* Tests for {@link S3ConnectionResolver}: connection dereferencing with per-use overrides, the
|
||||
* legacy embedded fallback, and the save-time access check that background sweeps skip.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class S3ConnectionResolverTest {
|
||||
|
||||
@Mock private IntegrationConfigRepository connections;
|
||||
@Mock private OwnershipService ownership;
|
||||
@Mock private UserService userService;
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesAConnectionAndMergesPerUseOptions() {
|
||||
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
|
||||
|
||||
S3Config config =
|
||||
resolver()
|
||||
.resolve(
|
||||
Map.of(
|
||||
"connectionId", 9L,
|
||||
"prefix", "incoming/",
|
||||
"mode", "snapshot"));
|
||||
|
||||
assertEquals("inbox", config.bucket());
|
||||
assertEquals("AKIAEXAMPLE", config.accessKeyId());
|
||||
assertEquals("incoming/", config.prefix());
|
||||
assertTrue(config.snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsAStringConnectionReference() {
|
||||
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
|
||||
|
||||
assertEquals("inbox", resolver().resolve(Map.of("connectionId", "9")).bucket());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToLegacyEmbeddedCredentials() {
|
||||
S3Config config =
|
||||
resolver()
|
||||
.resolve(
|
||||
Map.of(
|
||||
"bucket", "legacy",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh"));
|
||||
|
||||
assertEquals("legacy", config.bucket());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownDisabledOrWrongTypeConnections() {
|
||||
when(connections.findById(1L)).thenReturn(Optional.empty());
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> resolver().resolve(Map.of("connectionId", 1L)));
|
||||
|
||||
when(connections.findById(2L)).thenReturn(Optional.of(s3Connection(2L, false)));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> resolver().resolve(Map.of("connectionId", 2L)));
|
||||
|
||||
IntegrationConfig mcp = s3Connection(3L, true);
|
||||
mcp.setIntegrationType(IntegrationType.MCP);
|
||||
when(connections.findById(3L)).thenReturn(Optional.of(mcp));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> resolver().resolve(Map.of("connectionId", 3L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAuthenticatedSaverMustBeAllowedToUseTheConnection() {
|
||||
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
|
||||
User saver = new User();
|
||||
saver.setUsername("alice");
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(saver, null, java.util.List.of()));
|
||||
when(ownership.canUse(any(), any(IntegrationConfig.class), eq(saver))).thenReturn(false);
|
||||
|
||||
// Denied reads the same as unknown and never echoes the connection name, so ids can't be
|
||||
// enumerated by probing.
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> resolver().resolve(Map.of("connectionId", 9L)));
|
||||
try {
|
||||
resolver().resolve(Map.of("connectionId", 9L));
|
||||
} catch (IllegalArgumentException e) {
|
||||
org.junit.jupiter.api.Assertions.assertFalse(
|
||||
e.getMessage().contains("Claims bucket"),
|
||||
"access-denied error must not leak the connection name");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void backgroundSweepsWithNoUserSkipTheAccessCheck() {
|
||||
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
|
||||
|
||||
// No authentication in the context: resolution succeeds without consulting ownership.
|
||||
assertEquals("inbox", resolver().resolve(Map.of("connectionId", 9L)).bucket());
|
||||
}
|
||||
|
||||
private S3ConnectionResolver resolver() {
|
||||
return new S3ConnectionResolver(connections, ownership, userService);
|
||||
}
|
||||
|
||||
private static IntegrationConfig s3Connection(long id, boolean enabled) {
|
||||
IntegrationConfig connection = new IntegrationConfig();
|
||||
connection.setId(id);
|
||||
connection.setIntegrationType(IntegrationType.S3);
|
||||
connection.setName("Claims bucket");
|
||||
connection.setEnabled(enabled);
|
||||
connection.setConfig(
|
||||
"{\"bucket\":\"inbox\",\"accessKeyId\":\"AKIAEXAMPLE\","
|
||||
+ "\"secretAccessKey\":\"shh\"}");
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
|
||||
/**
|
||||
* Tests for {@link S3IntegrationValidator}: the S3 connection schema fails at save time - missing
|
||||
* credentials, bad endpoints, and private endpoints without the operator opt-in.
|
||||
*/
|
||||
class S3IntegrationValidatorTest {
|
||||
|
||||
@Test
|
||||
void acceptsACompleteConnection() {
|
||||
assertThatCode(
|
||||
() ->
|
||||
validator(false)
|
||||
.validate(
|
||||
Map.of(
|
||||
"bucket", "inbox",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh")))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingCredentialsOrBucket() {
|
||||
assertThatThrownBy(() -> validator(false).validate(Map.of("bucket", "inbox")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
validator(false)
|
||||
.validate(
|
||||
Map.of(
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAPrivateEndpointWithoutTheOperatorOptIn() {
|
||||
Map<String, Object> config =
|
||||
Map.of(
|
||||
"bucket", "inbox",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh",
|
||||
"endpoint", "http://localhost:9000");
|
||||
|
||||
assertThatThrownBy(() -> validator(false).validate(config))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("allowPrivateS3Endpoints");
|
||||
assertThatCode(() -> validator(true).validate(config)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void itOnlyClaimsTheS3Type() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(IntegrationType.S3, validator(false).type());
|
||||
}
|
||||
|
||||
private static S3IntegrationValidator validator(boolean allowPrivateEndpoints) {
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
properties.getPolicies().setAllowPrivateS3Endpoints(allowPrivateEndpoints);
|
||||
return new S3IntegrationValidator(properties);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import stirling.software.proprietary.access.service.OwnershipService;
|
||||
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/** Test fixtures for S3 connection plumbing shared across the policy S3 tests. */
|
||||
public final class S3TestConnections {
|
||||
|
||||
private S3TestConnections() {}
|
||||
|
||||
/**
|
||||
* A resolver for tests whose options embed credentials directly (the legacy pass-through path),
|
||||
* so its collaborators are never touched.
|
||||
*/
|
||||
public static S3ConnectionResolver legacyResolver() {
|
||||
return new S3ConnectionResolver(
|
||||
mock(IntegrationConfigRepository.class),
|
||||
mock(OwnershipService.class),
|
||||
mock(UserService.class));
|
||||
}
|
||||
}
|
||||
+55
-1
@@ -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
|
||||
|
||||
+150
@@ -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());
|
||||
}
|
||||
}
|
||||
+54
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
-1
@@ -91,7 +91,7 @@ springBoot {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.14.1'
|
||||
version = '2.14.2'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}.")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Generated
+2
@@ -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"
|
||||
|
||||
@@ -6240,6 +6394,10 @@ muted = "muted"
|
||||
nameLabel = "Name"
|
||||
namePlaceholder = "e.g. Compliance escalation"
|
||||
|
||||
[portal.agentBuilder.status]
|
||||
draft = "Draft"
|
||||
published = "Published"
|
||||
|
||||
[portal.agentBuilder.tabs]
|
||||
evals = "Evals"
|
||||
scenarios = "Scenarios"
|
||||
@@ -6463,7 +6621,7 @@ install = "Install"
|
||||
usage = "Usage"
|
||||
|
||||
[portal.catalogue.detail.locked]
|
||||
description = "{{name}} is included from the {{tier}} plan. Upgrade to embed it."
|
||||
description = "{{name}} is included from the {{plan}}. Upgrade to embed it."
|
||||
title = "Not available on your plan"
|
||||
|
||||
[portal.catalogue.detail.preview]
|
||||
@@ -6530,6 +6688,35 @@ title = "No components available"
|
||||
description = "GA components are available on Pay-as-you-go; a few Beta components are enterprise-only. Locked cards show an upgrade nudge."
|
||||
title = "Some components need a paid plan"
|
||||
|
||||
[portal.connections]
|
||||
createTitle = "New S3 connection"
|
||||
delete = "Delete"
|
||||
edit = "Edit"
|
||||
editTitle = "Edit S3 connection"
|
||||
subtitle = "Reusable S3 credentials that sources and pipeline outputs connect to."
|
||||
|
||||
[portal.connections.actions]
|
||||
new = "New connection"
|
||||
|
||||
[portal.connections.empty]
|
||||
description = "Add an S3 connection to reuse the same bucket and credentials across sources and pipeline outputs."
|
||||
title = "No connections yet"
|
||||
|
||||
[portal.connections.picker]
|
||||
cancel = "Cancel"
|
||||
createNew = "New connection..."
|
||||
placeholder = "Select a connection"
|
||||
save = "Save connection"
|
||||
|
||||
[portal.connections.s3.fields]
|
||||
name = "Connection name"
|
||||
namePlaceholder = "e.g. Claims bucket"
|
||||
|
||||
[portal.connections.table]
|
||||
bucket = "Bucket"
|
||||
name = "Name"
|
||||
region = "Region"
|
||||
|
||||
[portal.docs.authentication]
|
||||
codeCaption = "every request"
|
||||
eyebrow = "GETTING STARTED"
|
||||
@@ -7341,10 +7528,6 @@ operations_one = "Operation ({{count}})"
|
||||
operations_other = "Operations ({{count}})"
|
||||
output = "Output"
|
||||
removeStep = "Remove operation"
|
||||
s3Configure = "Configure"
|
||||
s3Done = "Done"
|
||||
s3ModalTitle = "Amazon S3 output"
|
||||
s3NotConfigured = "Not configured"
|
||||
s3PrefixHelp = "Outputs are uploaded under this key prefix."
|
||||
save = "Save changes"
|
||||
scheduleEvery = "Run every"
|
||||
@@ -7415,6 +7598,7 @@ manual = "Manual"
|
||||
schedule = "Scheduled"
|
||||
|
||||
[portal.policies]
|
||||
defaultName = "{{category}} Policy"
|
||||
subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original."
|
||||
title = "Policies"
|
||||
|
||||
@@ -7986,34 +8170,29 @@ primaryNav = "Primary navigation"
|
||||
switchApp = "Switch app"
|
||||
|
||||
[portal.sources]
|
||||
subtitle = "Reusable input connections that feed documents into Stirling. Configure a connection once, then reference it from any number of policies. Click a row for its config and which policies use it."
|
||||
subtitle = "Reusable input connections that feed documents into Stirling. Configure a source once, then reference it from any number of pipelines."
|
||||
title = "Sources"
|
||||
|
||||
[portal.sources.actions]
|
||||
agentBuilder = "Agent Builder"
|
||||
connectSource = "Connect source"
|
||||
|
||||
[portal.sources.builder]
|
||||
back = "Back to sources"
|
||||
cancel = "Cancel"
|
||||
create = "Create source"
|
||||
createTitle = "Connect a source"
|
||||
delete = "Delete"
|
||||
editTitle = "Edit source"
|
||||
enabled = "Enabled"
|
||||
save = "Save changes"
|
||||
|
||||
[portal.sources.delete]
|
||||
body = "Delete \"{{name}}\"? This can't be undone. Policies that reference it would need to be updated."
|
||||
cancel = "Cancel"
|
||||
confirm = "Delete"
|
||||
title = "Delete source?"
|
||||
|
||||
[portal.sources.detail]
|
||||
closeAriaLabel = "Close detail"
|
||||
delete = "Delete source"
|
||||
docs24h = "Last 24h"
|
||||
docs30d = "Last 30 days"
|
||||
docsTotal = "Total seen"
|
||||
docsTrend = "Documents over the last 30 days"
|
||||
documents = "Documents"
|
||||
edit = "Edit"
|
||||
notReferenced = "Not referenced by any policy, so it's safe to delete."
|
||||
pause = "Pause"
|
||||
resume = "Resume"
|
||||
subtitle = "{{type}} · {{status}}"
|
||||
usedBy = "Used by"
|
||||
|
||||
[portal.sources.empty]
|
||||
description = "Connect a folder (and, soon, cloud storage) so your policies have somewhere to pull documents from."
|
||||
title = "No sources connected yet"
|
||||
@@ -8029,10 +8208,15 @@ disabled = "Disabled"
|
||||
unused = "Unused"
|
||||
|
||||
[portal.sources.table]
|
||||
documents = "Documents"
|
||||
source = "Source"
|
||||
status = "Status"
|
||||
usedBy = "Policies"
|
||||
|
||||
[portal.sources.tabs]
|
||||
connections = "Connections"
|
||||
sources = "Sources"
|
||||
|
||||
[portal.sources.types.editor]
|
||||
description = "Documents your team has processed in the editor, across policy and AI runs."
|
||||
label = "Editor"
|
||||
@@ -8080,6 +8264,10 @@ label = "Access key ID"
|
||||
label = "Bucket"
|
||||
placeholder = "my-company-inbox"
|
||||
|
||||
[portal.sources.types.s3.fields.connection]
|
||||
helperText = "The stored connection holding the bucket and credentials. Reused by every source and pipeline output that references it."
|
||||
label = "Connection"
|
||||
|
||||
[portal.sources.types.s3.fields.endpoint]
|
||||
helperText = "Leave blank for Amazon S3. Set to use an S3-compatible service such as MinIO."
|
||||
label = "Custom endpoint"
|
||||
@@ -8109,21 +8297,14 @@ label = "Secret access key"
|
||||
label = "Source"
|
||||
|
||||
[portal.sources.wizard]
|
||||
back = "Back"
|
||||
cancel = "Cancel"
|
||||
continue = "Continue"
|
||||
editTitle = "Edit source"
|
||||
name = "Name"
|
||||
namePlaceholder = "e.g. Claims intake"
|
||||
save = "Save changes"
|
||||
subtitle = "Step {{current}} of {{total}} · {{label}}"
|
||||
title = "Connect a source"
|
||||
type = "Type"
|
||||
|
||||
[portal.sources.wizard.steps]
|
||||
chooseType = "Choose type"
|
||||
configure = "Configure"
|
||||
review = "Review & connect"
|
||||
[portal.tier]
|
||||
enterprise = "Enterprise plan"
|
||||
free = "Editor plan"
|
||||
pro = "Processor plan"
|
||||
|
||||
[portal.usage]
|
||||
managePayment = "Manage Payment"
|
||||
@@ -8925,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"
|
||||
@@ -10139,6 +10327,19 @@ resetPw = "Reset password"
|
||||
suspend = "Suspend"
|
||||
unlock = "Unlock account"
|
||||
|
||||
[users.activity]
|
||||
daysAgo = "{{count}}d ago"
|
||||
hoursAgo = "{{count}}h ago"
|
||||
justNow = "Just now"
|
||||
minutesAgo = "{{count}}m ago"
|
||||
monthsAgo_one = "{{count}} month ago"
|
||||
monthsAgo_other = "{{count}} months ago"
|
||||
never = "Never"
|
||||
weeksAgo_one = "{{count}} week ago"
|
||||
weeksAgo_other = "{{count}} weeks ago"
|
||||
yearsAgo_one = "{{count}} year ago"
|
||||
yearsAgo_other = "{{count}} years ago"
|
||||
|
||||
[users.cap]
|
||||
addProcessor = "+ Processor"
|
||||
approver = "Approves policy"
|
||||
@@ -10209,7 +10410,9 @@ by = "Invited by {{who}}"
|
||||
cancel = "Cancel"
|
||||
count = "{{count}} pending"
|
||||
desc = "Invited people who haven't joined yet. They hold a seat until they accept."
|
||||
expires = "Expires"
|
||||
expiresInDays_one = "Expires in {{count}} day"
|
||||
expiresInDays_other = "Expires in {{count}} days"
|
||||
expiresToday = "Expires today"
|
||||
title = "Pending invitations"
|
||||
|
||||
[users.loadError]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling PDF",
|
||||
"mainBinaryName": "Stirling-PDF",
|
||||
"version": "2.14.1",
|
||||
"version": "2.14.2",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -32,6 +32,10 @@ export const VALID_NAV_KEYS = [
|
||||
"adminEndpoints",
|
||||
"adminStorageSharing",
|
||||
"adminMcp",
|
||||
"adminAiGeneral",
|
||||
"adminAiModels",
|
||||
"adminAiDocuments",
|
||||
"adminAiLimits",
|
||||
"help",
|
||||
"legal",
|
||||
"backendThirdPartyLicenses",
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import {
|
||||
deserializeToolStep,
|
||||
getExecutableTools,
|
||||
serializeStepFromEndpoint,
|
||||
serializeToolStep,
|
||||
stepRequiresUpload,
|
||||
type WorkingToolStep,
|
||||
@@ -199,41 +198,6 @@ describe("serialize/deserialize round-trip", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeStepFromEndpoint", () => {
|
||||
test("maps a wizard step's UI params to the backend contract, filling defaults", () => {
|
||||
// The shape the policy setup wizard holds: an endpoint plus UI-shaped params
|
||||
// (redact's `wordsToRedact`), with several fields left to their defaults.
|
||||
const api = serializeStepFromEndpoint(
|
||||
"/api/v1/security/auto-redact",
|
||||
{ mode: "automatic", useRegex: true, wordsToRedact: ["ssn", "card"] },
|
||||
dynamicRegistry,
|
||||
);
|
||||
|
||||
expect(api.operation).toBe("/api/v1/security/auto-redact");
|
||||
// wordsToRedact -> listOfText (the field the backend actually reads), and the
|
||||
// frontend-only `mode` is dropped.
|
||||
expect(api.parameters).toMatchObject({ listOfText: "ssn\ncard" });
|
||||
expect(api.parameters).not.toHaveProperty("wordsToRedact");
|
||||
expect(api.parameters).not.toHaveProperty("mode");
|
||||
// Fields the wizard never set still get their defaults so the body is complete.
|
||||
expect(api.parameters).toHaveProperty("wholeWordSearch");
|
||||
expect(api.parameters).toHaveProperty("customPadding");
|
||||
});
|
||||
|
||||
test("passes an unmapped endpoint's params through unchanged", () => {
|
||||
expect(
|
||||
serializeStepFromEndpoint(
|
||||
"/api/v1/unknown/thing",
|
||||
{ keep: true },
|
||||
dynamicRegistry,
|
||||
),
|
||||
).toEqual({
|
||||
operation: "/api/v1/unknown/thing",
|
||||
parameters: { keep: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepRequiresUpload", () => {
|
||||
const step = (params: Record<string, unknown>): WorkingToolStep => ({
|
||||
toolId: "compress" as ToolId,
|
||||
|
||||
@@ -197,31 +197,6 @@ export function serializeToolStep(
|
||||
return { operation, parameters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a step held as an endpoint path plus frontend-shaped params - the form the policy setup
|
||||
* wizard keeps, where params match the tool's UI shape (e.g. redact's `wordsToRedact`) rather than
|
||||
* the backend contract - into the backend step contract, mapping params through the tool's
|
||||
* `toApiParams` (merged over its defaults, so fields the wizard never set still get their defaults).
|
||||
* The endpoint maps to a tool by path, so this works for dynamic-endpoint tools whose config
|
||||
* endpoint is a function. Endpoints that map to no known tool pass through unchanged.
|
||||
*/
|
||||
export function serializeStepFromEndpoint(
|
||||
operation: string,
|
||||
params: ErasedToolParams,
|
||||
registry: Partial<ToolRegistry>,
|
||||
): ToolApiStep {
|
||||
const match = findToolByEndpoint({ operation, parameters: params }, registry);
|
||||
const config = match?.[1].operationConfig;
|
||||
if (!config) return { operation, parameters: params };
|
||||
const merged = { ...(config.defaultParameters ?? {}), ...params };
|
||||
return {
|
||||
operation: resolveEndpoint(config, merged) ?? operation,
|
||||
parameters: config.toApiParams
|
||||
? (config.toApiParams(merged) as Record<string, unknown>)
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the registry tool for a stored step's endpoint: exact match for static endpoints, else
|
||||
* membership in a dynamic tool's declared `endpoints` set (replaying its function can't recover a
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor";
|
||||
|
||||
interface Params {
|
||||
a: number;
|
||||
}
|
||||
|
||||
// A minimal config that type-checks against the flatten endpoint's model.
|
||||
const CONFIG = {
|
||||
endpoint: "/api/v1/misc/flatten" as const,
|
||||
defaultParameters: { a: 1 } satisfies Params,
|
||||
toApiParams: (p: Params) => ({ renderDpi: p.a }),
|
||||
fromApiParams: (api: { renderDpi?: number }) => ({ a: api.renderDpi ?? 0 }),
|
||||
};
|
||||
|
||||
describe("describeToolOperation", () => {
|
||||
test("wraps the config's mappers and endpoint into a descriptor", () => {
|
||||
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
|
||||
expect(d.endpoint).toBe("/api/v1/misc/flatten");
|
||||
expect(d.toApi({ a: 200 })).toEqual({ renderDpi: 200 });
|
||||
});
|
||||
|
||||
test("fromApi merges the mapped values over the defaults", () => {
|
||||
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
|
||||
expect(d.fromApi({ renderDpi: 72 })).toEqual({ a: 72 });
|
||||
});
|
||||
|
||||
test("throws when the config lacks a mapper", () => {
|
||||
expect(() =>
|
||||
describeToolOperation("/api/v1/misc/flatten", {
|
||||
endpoint: "/api/v1/misc/flatten" as const,
|
||||
defaultParameters: { a: 1 },
|
||||
toApiParams: (p: Params) => ({ renderDpi: p.a }),
|
||||
}),
|
||||
).toThrow(/mappers/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Typed wrapper over a tool's `toApiParams`/`fromApiParams` mappers, binding one endpoint to safe
|
||||
* frontend<->backend parameter conversion.
|
||||
*/
|
||||
|
||||
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
|
||||
|
||||
export interface ToolOperationDescriptor<E extends ToolEndpoint, TParams> {
|
||||
readonly endpoint: E;
|
||||
readonly defaultParameters: TParams;
|
||||
toApi(params: TParams): ToolApiParams[E];
|
||||
/** Backend model -> full frontend params (defaults merged under the mapped values). */
|
||||
fromApi(api: ToolApiParams[E]): TParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural subset of a tool's config. `CE` is the config's declared endpoint type, inferred from
|
||||
* the `endpoint` field: the literal for static tools, or the whole `ToolEndpoint` union for
|
||||
* dynamic-endpoint tools (whose endpoint is a function typed against the union).
|
||||
*/
|
||||
export interface BidirectionalToolConfig<TParams, CE extends ToolEndpoint> {
|
||||
endpoint: CE | null | ((params: TParams) => CE | null);
|
||||
defaultParameters?: TParams;
|
||||
toApiParams?(params: TParams): ToolApiParams[CE];
|
||||
fromApiParams?(api: ToolApiParams[CE]): Partial<TParams>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin a config to `endpoint` (passed explicitly, since dynamic-endpoint tools declare `endpoint` as
|
||||
* a function). `E extends CE` rejects pairing a static tool's config with the wrong endpoint, while
|
||||
* allowing a dynamic tool whose `CE` is the full union. Throws when mappers or defaults are missing.
|
||||
*/
|
||||
export function describeToolOperation<
|
||||
E extends CE,
|
||||
CE extends ToolEndpoint,
|
||||
TParams,
|
||||
>(
|
||||
endpoint: E,
|
||||
config: BidirectionalToolConfig<TParams, CE>,
|
||||
): ToolOperationDescriptor<E, TParams> {
|
||||
const { toApiParams, fromApiParams, defaultParameters } = config;
|
||||
if (!toApiParams || !fromApiParams || defaultParameters === undefined) {
|
||||
throw new Error(
|
||||
`describeToolOperation: "${endpoint}" is missing mappers or defaults`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
endpoint,
|
||||
defaultParameters,
|
||||
// A dynamic tool's mapper is typed against the union; narrow to this endpoint (sound - the
|
||||
// runtime mapper produces this endpoint's model).
|
||||
toApi: (params) => toApiParams(params) as ToolApiParams[E],
|
||||
fromApi: (api) =>
|
||||
({
|
||||
...defaultParameters,
|
||||
...fromApiParams(api as ToolApiParams[CE]),
|
||||
}) as TParams,
|
||||
};
|
||||
}
|
||||
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: "2.14.1",
|
||||
appVersion: "2.14.2",
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
serverPort: 8080,
|
||||
|
||||
@@ -270,8 +270,29 @@
|
||||
--grad-banner: linear-gradient(135deg, #0f172a 0%, #111827 50%, #1a1535 100%);
|
||||
}
|
||||
|
||||
/* Always-dark code palette. Not theme-switched. */
|
||||
:root {
|
||||
/* Code palette — theme-aware: a light GitHub-style box in light mode, the dark
|
||||
terminal palette in dark mode. (CodeBlock renders plain text, so only the
|
||||
base colours are load-bearing; the syntax slots are kept for future Shiki. */
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
--code-bg: #f6f8fa;
|
||||
--code-bg-alt: #eef1f4;
|
||||
--code-bg-header: #eaeef2;
|
||||
--code-text: #1f2328;
|
||||
--code-dim: #656d76;
|
||||
--code-muted: #8c959f;
|
||||
--code-keyword: #cf222e;
|
||||
--code-string: #0a3069;
|
||||
--code-number: #0550ae;
|
||||
--code-fn: #8250df;
|
||||
--code-type: #953800;
|
||||
--code-property: #0550ae;
|
||||
--code-comment: #6e7781;
|
||||
--code-border: #d0d7de;
|
||||
/* Window-chrome traffic-light dots in the code-block header. */
|
||||
--code-dot: #d0d7de;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--code-bg: #0f172a;
|
||||
--code-bg-alt: #1e293b;
|
||||
--code-bg-header: #1a2332;
|
||||
@@ -286,12 +307,16 @@
|
||||
--code-property: #93c5fd;
|
||||
--code-comment: #475569;
|
||||
--code-border: #1e293b;
|
||||
/* Window-chrome traffic-light dots in the code-block header. */
|
||||
--code-dot: #475569;
|
||||
}
|
||||
|
||||
/* Radii / typography / motion / spacing / z-index — theme-stable */
|
||||
:root {
|
||||
/* Home hero strip navy. Theme-stable by design — the hero keeps this deep
|
||||
navy in both light and dark (it's a branded surface, like the assistant
|
||||
header), so it's defined once here rather than in the light/dark blocks. */
|
||||
--color-hero-navy: #16213e;
|
||||
|
||||
--radius-xs: 0.1875rem;
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
|
||||
@@ -61,11 +61,14 @@
|
||||
background: var(--button-bg, transparent) !important;
|
||||
}
|
||||
|
||||
/* Disabled buttons in dark read as a muted surface (not a dimmed accent that
|
||||
still looks clickable, and not an invisible transparent pill). Covers every
|
||||
variant so a disabled primary and a disabled secondary look alike. */
|
||||
[data-theme="dark"] .sui-btn.mantine-Button-root:disabled:not([data-loading]),
|
||||
[data-theme="dark"]
|
||||
.sui-btn--primary.mantine-Button-root:disabled:not([data-loading]),
|
||||
[data-theme="dark"]
|
||||
.sui-btn--primary.mantine-Button-root[data-disabled]:not([data-loading]) {
|
||||
background: var(--button-bg);
|
||||
color: var(--button-color);
|
||||
opacity: 0.55;
|
||||
.sui-btn.mantine-Button-root[data-disabled]:not([data-loading]) {
|
||||
background: var(--color-bg-muted);
|
||||
color: var(--color-text-5);
|
||||
border-color: transparent;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.sui-code__copy:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
/* Subtle tint that reads on both the light and dark code surfaces. */
|
||||
background: color-mix(in srgb, var(--code-text) 8%, transparent);
|
||||
}
|
||||
.sui-code__pre {
|
||||
margin: 0;
|
||||
|
||||
@@ -19,6 +19,12 @@ export interface TableProps<T> {
|
||||
rowKey: (row: T) => string;
|
||||
/** Makes rows interactive (hover + click + keyboard). */
|
||||
onRowClick?: (row: T) => void;
|
||||
/**
|
||||
* Per-row gate for interactivity, checked only when {@link onRowClick} is set. A row for which
|
||||
* this returns false is inert: no click/keyboard, and not announced as a button. Defaults to
|
||||
* all rows interactive.
|
||||
*/
|
||||
isRowInteractive?: (row: T) => boolean;
|
||||
/** Rendered in place of the body when there are no rows. */
|
||||
empty?: ReactNode;
|
||||
className?: string;
|
||||
@@ -35,6 +41,7 @@ export function Table<T>({
|
||||
rows,
|
||||
rowKey,
|
||||
onRowClick,
|
||||
isRowInteractive,
|
||||
empty,
|
||||
className,
|
||||
}: TableProps<T>) {
|
||||
@@ -66,38 +73,42 @@ export function Table<T>({
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
className={
|
||||
interactive
|
||||
? "sui-table__row sui-table__row--interactive"
|
||||
: "sui-table__row"
|
||||
}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
role={interactive ? "button" : undefined}
|
||||
onKeyDown={
|
||||
interactive
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onRowClick?.(row);
|
||||
rows.map((row) => {
|
||||
const rowInteractive =
|
||||
interactive && (isRowInteractive?.(row) ?? true);
|
||||
return (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
className={
|
||||
rowInteractive
|
||||
? "sui-table__row sui-table__row--interactive"
|
||||
: "sui-table__row"
|
||||
}
|
||||
onClick={rowInteractive ? () => onRowClick?.(row) : undefined}
|
||||
tabIndex={rowInteractive ? 0 : undefined}
|
||||
role={rowInteractive ? "button" : undefined}
|
||||
onKeyDown={
|
||||
rowInteractive
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onRowClick?.(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<td
|
||||
key={c.key}
|
||||
className={`sui-table__td sui-table__td--${c.align ?? "left"}`}
|
||||
>
|
||||
{c.render(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<td
|
||||
key={c.key}
|
||||
className={`sui-table__td sui-table__td--${c.align ?? "left"}`}
|
||||
>
|
||||
{c.render(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Documents } from "@portal/views/Documents";
|
||||
import { Pipelines } from "@portal/views/Pipelines";
|
||||
import { PipelineBuilder } from "@portal/views/PipelineBuilder";
|
||||
import { Sources } from "@portal/views/Sources";
|
||||
import { SourceBuilder } from "@portal/views/SourceBuilder";
|
||||
import { AgentBuilder } from "@portal/views/AgentBuilder";
|
||||
import { Policies } from "@portal/views/Policies";
|
||||
import { Components } from "@portal/views/Components";
|
||||
@@ -36,6 +37,14 @@ export function ViewRouter() {
|
||||
element={<PipelineBuilder />}
|
||||
/>
|
||||
<Route path={rel(VIEW_PATHS.sources)} element={<Sources />} />
|
||||
<Route
|
||||
path={`${rel(VIEW_PATHS.sources)}/new`}
|
||||
element={<SourceBuilder />}
|
||||
/>
|
||||
<Route
|
||||
path={`${rel(VIEW_PATHS.sources)}/:id`}
|
||||
element={<SourceBuilder />}
|
||||
/>
|
||||
<Route
|
||||
path={rel(VIEW_PATHS["agent-builder"])}
|
||||
element={<AgentBuilder />}
|
||||
|
||||
@@ -99,6 +99,11 @@ export const AGENT_STATUS_TONE: Record<AgentStatus, "success" | "neutral"> = {
|
||||
draft: "neutral",
|
||||
};
|
||||
|
||||
export const AGENT_STATUS_LABEL: Record<AgentStatus, string> = {
|
||||
published: "portal.agentBuilder.status.published",
|
||||
draft: "portal.agentBuilder.status.draft",
|
||||
};
|
||||
|
||||
/**
|
||||
* Catalogue of tools an agent can be granted or denied. Surfaced as the chip
|
||||
* palette in restricted mode so the deny list is picked from a known set
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Integrations service layer: stored connections (S3 today; MCP/API later) that
|
||||
* policy sources and pipeline outputs reference by id instead of embedding
|
||||
* credentials. Secrets are write-only - reads return them masked, and sending
|
||||
* the mask back on update keeps the stored value.
|
||||
*/
|
||||
import { apiClient } from "@portal/api/http";
|
||||
|
||||
export type IntegrationType = "S3" | "MCP" | "API";
|
||||
export type OwnerScope = "USER" | "TEAM" | "SERVER";
|
||||
|
||||
/** Mirrors the backend IntegrationConfigResponse; `config` values are masked. */
|
||||
export interface IntegrationConfig {
|
||||
id: number;
|
||||
integrationType: IntegrationType;
|
||||
name: string;
|
||||
scope: OwnerScope;
|
||||
ownerUserId: number | null;
|
||||
ownerTeamId: number | null;
|
||||
enabled: boolean;
|
||||
locked: boolean;
|
||||
defaultAccess: string;
|
||||
config: Record<string, unknown>;
|
||||
canManage: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Create/update body; omitted fields keep their stored values on update. */
|
||||
export interface IntegrationConfigRequest {
|
||||
integrationType?: IntegrationType;
|
||||
name?: string;
|
||||
scope?: OwnerScope;
|
||||
ownerTeamId?: number | null;
|
||||
enabled?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function fetchIntegrations(): Promise<IntegrationConfig[]> {
|
||||
return apiClient.local.json<IntegrationConfig[]>("/api/v1/integrations");
|
||||
}
|
||||
|
||||
/** The S3 connections the caller may use, for source/output pickers. */
|
||||
export async function fetchS3Connections(): Promise<IntegrationConfig[]> {
|
||||
return (await fetchIntegrations()).filter(
|
||||
(integration) => integration.integrationType === "S3",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createIntegration(
|
||||
body: IntegrationConfigRequest,
|
||||
): Promise<IntegrationConfig> {
|
||||
return apiClient.local.json<IntegrationConfig>("/api/v1/integrations", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateIntegration(
|
||||
id: number,
|
||||
body: IntegrationConfigRequest,
|
||||
): Promise<IntegrationConfig> {
|
||||
return apiClient.local.json<IntegrationConfig>(
|
||||
`/api/v1/integrations/${encodeURIComponent(id)}`,
|
||||
{ method: "PUT", body },
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteIntegration(id: number): Promise<void> {
|
||||
await apiClient.local.json<void>(
|
||||
`/api/v1/integrations/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,12 @@
|
||||
* approach the editor uses for its own catalogue view.
|
||||
*/
|
||||
|
||||
import type { TFunction } from "i18next";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import { fromWirePolicy, toWirePolicy } from "@app/policies/codec";
|
||||
import { runsToActivity, runsToStats } from "@app/policies/runs";
|
||||
import { policyStep, type PolicyToolStep } from "@app/policies/operations";
|
||||
import type { ToolEndpoint } from "@app/types/toolApiTypes";
|
||||
import type {
|
||||
PolicyDecodedState,
|
||||
PolicyRunView,
|
||||
@@ -65,7 +68,7 @@ export interface PolicyConfigDef {
|
||||
rules: string[];
|
||||
scopeLabel: string;
|
||||
fields: PolicyField[];
|
||||
defaultOperations: WirePipelineStep[];
|
||||
defaultOperations: PolicyToolStep[];
|
||||
}
|
||||
|
||||
export interface PolicyState {
|
||||
@@ -127,20 +130,11 @@ export interface CatalogueEntry {
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Tool → endpoint registry */
|
||||
/* Endpoint display labels */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const TOOL_ENDPOINTS: Record<string, string> = {
|
||||
redact: "/api/v1/security/auto-redact",
|
||||
sanitize: "/api/v1/security/sanitize-pdf",
|
||||
watermark: "/api/v1/security/add-watermark",
|
||||
ocr: "/api/v1/misc/ocr-pdf",
|
||||
flatten: "/api/v1/misc/flatten",
|
||||
compress: "/api/v1/misc/compress-pdf",
|
||||
};
|
||||
|
||||
/** Values are i18n keys — render with t(). */
|
||||
export const ENDPOINT_LABELS: Record<string, string> = {
|
||||
/** i18n keys keyed by {@link ToolEndpoint}; labels stored steps in the detail view. */
|
||||
export const ENDPOINT_LABELS: Partial<Record<ToolEndpoint, string>> = {
|
||||
"/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact",
|
||||
"/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf",
|
||||
"/api/v1/security/add-watermark": "portal.policies.endpoints.addWatermark",
|
||||
@@ -153,7 +147,8 @@ export function humanizeEndpoint(
|
||||
path: string,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (ENDPOINT_LABELS[path]) return t(ENDPOINT_LABELS[path]);
|
||||
const label = ENDPOINT_LABELS[path as ToolEndpoint];
|
||||
if (label) return t(label);
|
||||
const last = path.split("/").filter(Boolean).pop() ?? path;
|
||||
return last
|
||||
.replace(/-/g, " ")
|
||||
@@ -229,10 +224,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.ingestion.rules.3",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [
|
||||
{ operation: TOOL_ENDPOINTS.ocr, parameters: {} },
|
||||
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
|
||||
],
|
||||
defaultOperations: [policyStep("ocr"), policyStep("flatten")],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.ingestion.fields.minConfidence",
|
||||
@@ -259,32 +251,16 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.redact,
|
||||
parameters: {
|
||||
mode: "automatic",
|
||||
useRegex: true,
|
||||
convertPDFToImage: true,
|
||||
wordsToRedact: DEFAULT_PII_PATTERNS,
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.sanitize,
|
||||
parameters: {
|
||||
removeJavaScript: true,
|
||||
removeEmbeddedFiles: false,
|
||||
removeMetadata: false,
|
||||
removeLinks: false,
|
||||
removeFonts: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.watermark,
|
||||
// convertPDFToImage bakes the watermark in so it can't be stripped
|
||||
parameters: {
|
||||
convertPDFToImage: true,
|
||||
},
|
||||
},
|
||||
// Flatten to image so redactions can't be lifted off.
|
||||
policyStep("redact", {
|
||||
useRegex: true,
|
||||
convertPDFToImage: true,
|
||||
wordsToRedact: DEFAULT_PII_PATTERNS,
|
||||
}),
|
||||
// JavaScript removal only; the tool enables removeEmbeddedFiles by default, so turn it off.
|
||||
policyStep("sanitize", { removeEmbeddedFiles: false }),
|
||||
// Bake in via image so it can't be stripped.
|
||||
policyStep("watermark", { convertPDFToImage: true }),
|
||||
],
|
||||
fields: [],
|
||||
},
|
||||
@@ -296,10 +272,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.compliance.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [
|
||||
{ operation: TOOL_ENDPOINTS.sanitize, parameters: {} },
|
||||
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
|
||||
],
|
||||
defaultOperations: [policyStep("sanitize"), policyStep("flatten")],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.compliance.fields.frameworks",
|
||||
@@ -342,7 +315,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.routing.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
|
||||
defaultOperations: [policyStep("compress")],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.routing.fields.destination",
|
||||
@@ -373,7 +346,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.retention.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
|
||||
defaultOperations: [policyStep("compress")],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.retention.fields.keepFor",
|
||||
@@ -556,17 +529,30 @@ const DEFAULT_RETRY_DELAY = 5;
|
||||
// POST /api/v1/policies endpoint. The real backend ignores unknown fields.
|
||||
type CatalogueWireBody = WirePolicy & { categoryId: string };
|
||||
|
||||
/**
|
||||
* The persisted policy name derived from its category, e.g. "Security Policy".
|
||||
* `category.label` is an i18n key, so translate it before building the name;
|
||||
* otherwise the raw key is persisted and surfaces in the UI (e.g. the Sources
|
||||
* "Used by" pill).
|
||||
*/
|
||||
function policyDisplayName(entry: CatalogueEntry, t: TFunction): string {
|
||||
return t("portal.policies.defaultName", {
|
||||
category: t(entry.category.label),
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a wire policy from a setup wizard result. */
|
||||
export function buildWireFromSetup(
|
||||
entry: CatalogueEntry,
|
||||
result: PolicySetupResult,
|
||||
t: TFunction,
|
||||
enabled = true,
|
||||
): CatalogueWireBody {
|
||||
return {
|
||||
categoryId: entry.category.id,
|
||||
...toWirePolicy({
|
||||
id: entry.policy?.state.backendId ?? "",
|
||||
name: `${entry.category.label} Policy`,
|
||||
name: policyDisplayName(entry, t),
|
||||
enabled,
|
||||
categoryId: entry.category.id,
|
||||
sources: result.sources,
|
||||
@@ -589,13 +575,14 @@ export function buildWireFromState(
|
||||
entry: CatalogueEntry,
|
||||
policy: DecoratedPolicy,
|
||||
enabled: boolean,
|
||||
t: TFunction,
|
||||
): CatalogueWireBody {
|
||||
const s = policy.state;
|
||||
return {
|
||||
categoryId: entry.category.id,
|
||||
...toWirePolicy({
|
||||
id: s.backendId ?? "",
|
||||
name: `${entry.category.label} Policy`,
|
||||
name: policyDisplayName(entry, t),
|
||||
enabled,
|
||||
categoryId: entry.category.id,
|
||||
sources: s.sources,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// The bare i18next singleton (the same instance @app/i18n initializes at
|
||||
// startup), imported directly so this data module doesn't pull i18n's
|
||||
// init side effects into unit tests that mock react-i18next.
|
||||
import i18n from "i18next";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
@@ -269,22 +273,23 @@ function roleIdFor(u: AdminUserSummaryDto): RoleId {
|
||||
|
||||
/** A member's last-seen time as plain language; "Never" when no session is tracked. */
|
||||
function relativeTime(value: number | string | undefined): string {
|
||||
if (value === undefined || value === null) return "Never";
|
||||
if (value === undefined || value === null)
|
||||
return i18n.t("users.activity.never");
|
||||
const ts = typeof value === "string" ? Date.parse(value) : value;
|
||||
if (!Number.isFinite(ts) || ts <= 0) return "Never";
|
||||
if (!Number.isFinite(ts) || ts <= 0) return i18n.t("users.activity.never");
|
||||
const mins = Math.max(0, Math.round((Date.now() - ts) / 60000));
|
||||
if (mins < 1) return "Just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
if (mins < 1) return i18n.t("users.activity.justNow");
|
||||
if (mins < 60) return i18n.t("users.activity.minutesAgo", { count: mins });
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (hours < 24) return i18n.t("users.activity.hoursAgo", { count: hours });
|
||||
const days = Math.round(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
if (days < 7) return i18n.t("users.activity.daysAgo", { count: days });
|
||||
const weeks = Math.round(days / 7);
|
||||
if (weeks < 5) return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`;
|
||||
if (weeks < 5) return i18n.t("users.activity.weeksAgo", { count: weeks });
|
||||
const months = Math.round(days / 30);
|
||||
if (months < 12) return months <= 1 ? "1 month ago" : `${months} months ago`;
|
||||
if (months < 12) return i18n.t("users.activity.monthsAgo", { count: months });
|
||||
const years = Math.round(days / 365);
|
||||
return years <= 1 ? "1 year ago" : `${years} years ago`;
|
||||
return i18n.t("users.activity.yearsAgo", { count: years });
|
||||
}
|
||||
|
||||
/** 0 / huge sentinel license values mean "no seat limit". */
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: #16213e;
|
||||
background: var(--color-hero-navy);
|
||||
}
|
||||
|
||||
.portal-editor-hero__logo {
|
||||
@@ -123,7 +123,7 @@
|
||||
.portal-editor-hero__action .portal-editor-hero__cta.sui-btn {
|
||||
background: #ffffff;
|
||||
border-color: #ffffff;
|
||||
color: #16213e;
|
||||
color: var(--color-hero-navy);
|
||||
}
|
||||
.portal-editor-hero__action .portal-editor-hero__cta.sui-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
|
||||
@@ -26,7 +26,7 @@ export function ProcessingStatusStrip() {
|
||||
style={{ background: TIER_INFO[tier].dotColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
{TIER_INFO[tier].label}
|
||||
{t(TIER_INFO[tier].labelKey)}
|
||||
</span>
|
||||
<span className="portal-statusstrip__sep" aria-hidden>
|
||||
·
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: #16213e;
|
||||
background: var(--color-hero-navy);
|
||||
}
|
||||
|
||||
.portal-welcome__brand {
|
||||
@@ -90,7 +90,7 @@
|
||||
.portal-welcome__header .portal-welcome__cta.sui-btn {
|
||||
background: #ffffff;
|
||||
border-color: #ffffff;
|
||||
color: #16213e;
|
||||
color: var(--color-hero-navy);
|
||||
}
|
||||
.portal-welcome__header .portal-welcome__cta.sui-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StatusBadge, Tabs, type TabItem } from "@app/ui";
|
||||
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
|
||||
import {
|
||||
type Agent,
|
||||
AGENT_STATUS_LABEL,
|
||||
AGENT_STATUS_TONE,
|
||||
} from "@portal/api/agents";
|
||||
import { ScenariosPanel } from "@portal/components/agent-builder/ScenariosPanel";
|
||||
import { ToolsPanel } from "@portal/components/agent-builder/ToolsPanel";
|
||||
import { EvalsPanel } from "@portal/components/agent-builder/EvalsPanel";
|
||||
@@ -52,7 +56,7 @@ export function AgentBuilderPanel({
|
||||
</div>
|
||||
<div className="portal-agents__builder-meta">
|
||||
<StatusBadge tone={AGENT_STATUS_TONE[agent.status]} size="sm">
|
||||
{agent.status}
|
||||
{t(AGENT_STATUS_LABEL[agent.status])}
|
||||
</StatusBadge>
|
||||
<code className="portal-agents__builder-version">
|
||||
{agent.version}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
|
||||
import {
|
||||
type Agent,
|
||||
AGENT_STATUS_LABEL,
|
||||
AGENT_STATUS_TONE,
|
||||
} from "@portal/api/agents";
|
||||
import { Button, StatusBadge } from "@app/ui";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
@@ -40,7 +44,7 @@ export function AgentSelector({
|
||||
</span>
|
||||
<span className="portal-agents__selector-meta">
|
||||
<StatusBadge tone={AGENT_STATUS_TONE[a.status]} size="sm">
|
||||
{a.status}
|
||||
{t(AGENT_STATUS_LABEL[a.status])}
|
||||
</StatusBadge>
|
||||
<code className="portal-agents__selector-version">{a.version}</code>
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, StatusBadge } from "@app/ui";
|
||||
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
|
||||
import {
|
||||
type Agent,
|
||||
AGENT_STATUS_LABEL,
|
||||
AGENT_STATUS_TONE,
|
||||
} from "@portal/api/agents";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
interface VersionsPanelProps {
|
||||
@@ -52,7 +56,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) {
|
||||
{v.version}
|
||||
</code>
|
||||
<StatusBadge tone={AGENT_STATUS_TONE[v.status]} size="sm">
|
||||
{v.status}
|
||||
{t(AGENT_STATUS_LABEL[v.status])}
|
||||
</StatusBadge>
|
||||
{isCurrent && (
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
} from "@app/ui";
|
||||
import {
|
||||
type SdkComponent,
|
||||
BILLING_UNIT_LABEL,
|
||||
MATURITY_META,
|
||||
formatPrice,
|
||||
} from "@portal/api/sdkComponents";
|
||||
import { TIER_INFO } from "@portal/contexts/TierContext";
|
||||
import { ComponentPropsTable } from "@portal/components/catalogue/ComponentPropsTable";
|
||||
import "@portal/views/Components.css";
|
||||
|
||||
@@ -113,7 +115,7 @@ export function ComponentDetailModal({
|
||||
title={t("portal.catalogue.detail.locked.title")}
|
||||
description={t("portal.catalogue.detail.locked.description", {
|
||||
name: component.name,
|
||||
tier: component.minTier,
|
||||
plan: t(TIER_INFO[component.minTier].labelKey),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
@@ -205,7 +207,7 @@ export function ComponentDetailModal({
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.catalogue.detail.stats.billedOn")}
|
||||
value={component.pricing.unit}
|
||||
value={t(BILLING_UNIT_LABEL[component.pricing.unit])}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.catalogue.detail.stats.freeQuota")}
|
||||
@@ -220,7 +222,7 @@ export function ComponentDetailModal({
|
||||
</div>
|
||||
<p className="portal-components__pricing-note">
|
||||
{t("portal.catalogue.detail.pricing.note", {
|
||||
unit: component.pricing.unit,
|
||||
unit: t(BILLING_UNIT_LABEL[component.pricing.unit]),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -157,7 +157,7 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) {
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
navigate(`${toPortalPath(VIEW_PATHS.sources)}?new`)
|
||||
navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`)
|
||||
}
|
||||
>
|
||||
{t("portal.documents.queue.empty.connectSource")}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
|
||||
rightSection={
|
||||
<span className="portal-infra__key-head-right">
|
||||
<StatusBadge tone={KEY_TONE[apiKey.status]} size="sm">
|
||||
{KEY_LABEL[apiKey.status]}
|
||||
{t(KEY_LABEL[apiKey.status])}
|
||||
</StatusBadge>
|
||||
<span
|
||||
className={"portal-infra__chevron" + (open ? " is-open" : "")}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
|
||||
import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// A stand-in tool-settings UI that uses the shared editor Tooltip. The Tooltip
|
||||
// pulls in the Preferences + Sidebar contexts, which the portal does not mount
|
||||
// app-wide — so this reproduces the "usePreferences must be used within a
|
||||
// PreferencesProvider" crash unless PipelineStepSettings supplies them.
|
||||
function TooltipSettings() {
|
||||
return (
|
||||
<Tooltip content="help">
|
||||
<button type="button">field</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const step = {
|
||||
support: "editable",
|
||||
toolId: "compress",
|
||||
params: {},
|
||||
} as unknown as WorkingToolStep;
|
||||
|
||||
const registry = {
|
||||
compress: { automationSettings: TooltipSettings },
|
||||
} as unknown as Partial<ToolRegistry>;
|
||||
|
||||
describe("PipelineStepSettings", () => {
|
||||
it("renders reused editor tool settings (which use the shared Tooltip) without app-wide Preferences/Sidebar providers", () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<MantineProvider>
|
||||
<PipelineStepSettings
|
||||
step={step}
|
||||
registry={registry}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(screen.getByText("field")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner } from "@app/ui";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
||||
import { type ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { type ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
import { type WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
|
||||
@@ -46,14 +48,18 @@ export function PipelineStepSettings({
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Settings
|
||||
parameters={step.params}
|
||||
onParameterChange={(key, value) =>
|
||||
onChange({ ...step.params, [key]: value })
|
||||
}
|
||||
disabled={false}
|
||||
/>
|
||||
</Suspense>
|
||||
<PreferencesProvider>
|
||||
<SidebarProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Settings
|
||||
parameters={step.params}
|
||||
onParameterChange={(key, value) =>
|
||||
onChange({ ...step.params, [key]: value })
|
||||
}
|
||||
disabled={false}
|
||||
/>
|
||||
</Suspense>
|
||||
</SidebarProvider>
|
||||
</PreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fireEvent,
|
||||
render as baseRender,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
|
||||
import {
|
||||
POLICY_CATEGORIES,
|
||||
POLICY_CONFIG,
|
||||
type CatalogueEntry,
|
||||
type DecoratedPolicy,
|
||||
type PolicySetupResult,
|
||||
type PipelineStep,
|
||||
} from "@portal/api/policies";
|
||||
|
||||
const render = (ui: Parameters<typeof baseRender>[0]) =>
|
||||
baseRender(ui, { wrapper: MantineProvider });
|
||||
|
||||
// Deterministic i18n: return the fallback when given, else the key. initReactI18next is stubbed
|
||||
// because the import graph pulls core/i18n.ts, which registers it as a plugin.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
// Second arg is a string fallback in some call sites and an interpolation object in others;
|
||||
// only treat a string as the fallback.
|
||||
t: (key: string, fallback?: unknown) =>
|
||||
typeof fallback === "string" ? fallback : key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
initReactI18next: { type: "3rdParty", init: vi.fn() },
|
||||
}));
|
||||
|
||||
const fetchSources = vi.fn();
|
||||
vi.mock("@portal/api/sources", () => ({
|
||||
fetchSources: () => fetchSources(),
|
||||
}));
|
||||
|
||||
const CONTINUE = "portal.policies.wizard.actions.continue";
|
||||
const SAVE_CHANGES = "portal.policies.wizard.actions.saveChanges";
|
||||
const ENABLE = "portal.policies.wizard.actions.enablePolicy";
|
||||
|
||||
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
|
||||
const securityConfig = POLICY_CONFIG.security;
|
||||
|
||||
function editEntry(steps: PipelineStep[]): CatalogueEntry {
|
||||
const policy: DecoratedPolicy = {
|
||||
category: security,
|
||||
config: securityConfig,
|
||||
state: {
|
||||
configured: true,
|
||||
status: "active",
|
||||
sources: ["editor"],
|
||||
scopeTypes: [],
|
||||
reviewerEmail: "",
|
||||
fieldValues: {},
|
||||
runOn: "upload",
|
||||
outputMode: "new_version",
|
||||
outputName: "",
|
||||
outputNamePosition: "suffix",
|
||||
maxRetries: 0,
|
||||
retryDelayMinutes: 0,
|
||||
backendId: "pol-1",
|
||||
isDefault: true,
|
||||
},
|
||||
steps,
|
||||
stats: { enforced: 0, dataProcessed: "-", activeFor: "-" },
|
||||
activity: [],
|
||||
};
|
||||
return { category: security, config: securityConfig, policy };
|
||||
}
|
||||
|
||||
/** Advance the wizard from the workflow tab to the settings tab and submit. */
|
||||
async function submitWizard(saveLabel: string) {
|
||||
fireEvent.click(await screen.findByRole("button", { name: CONTINUE }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: saveLabel }));
|
||||
}
|
||||
|
||||
describe("PolicySetupWizard", () => {
|
||||
beforeEach(() => {
|
||||
fetchSources.mockResolvedValue({ sources: [] });
|
||||
});
|
||||
|
||||
it("round-trips a saved step's backend params on edit", async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
const entry = editEntry([
|
||||
{
|
||||
operation: "/api/v1/security/auto-redact",
|
||||
parameters: { listOfText: "foo\nbar", useRegex: true },
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
|
||||
);
|
||||
await submitWizard(SAVE_CHANGES);
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
|
||||
// Only the saved tool is enabled on edit, and its patterns survive the wire -> UI -> wire trip.
|
||||
expect(result.steps).toEqual([
|
||||
expect.objectContaining({
|
||||
operation: "/api/v1/security/auto-redact",
|
||||
parameters: expect.objectContaining({ listOfText: "foo\nbar" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("seeds the preset chain for a new policy (redact + sanitize on, watermark off)", async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
const entry: CatalogueEntry = {
|
||||
category: security,
|
||||
config: securityConfig,
|
||||
policy: null,
|
||||
};
|
||||
|
||||
render(
|
||||
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
|
||||
);
|
||||
await submitWizard(ENABLE);
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
|
||||
const endpoints = result.steps.map((s) => s.operation);
|
||||
expect(endpoints).toEqual([
|
||||
"/api/v1/security/auto-redact",
|
||||
"/api/v1/security/sanitize-pdf",
|
||||
]);
|
||||
// Redact carries the preset PII patterns as the backend's listOfText.
|
||||
const redact = result.steps[0].parameters as { listOfText?: string };
|
||||
expect(redact.listOfText).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -13,23 +13,24 @@ import {
|
||||
} from "@app/ui";
|
||||
import { SettingsRow } from "@app/ui/SettingsRow";
|
||||
import {
|
||||
TOOL_ENDPOINTS,
|
||||
humanizeEndpoint,
|
||||
type CatalogueEntry,
|
||||
type PipelineStep,
|
||||
type PolicySetupResult,
|
||||
} from "@portal/api/policies";
|
||||
import type { ToolRegistry, ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import {
|
||||
deserializeToolStep,
|
||||
serializeStepFromEndpoint,
|
||||
} from "@app/hooks/tools/shared/toolAutomation";
|
||||
policyEndpoint,
|
||||
policyStepFromWire,
|
||||
policyStepToWire,
|
||||
type PolicyParams,
|
||||
type PolicyToolId,
|
||||
type PolicyToolStep,
|
||||
} from "@app/policies/operations";
|
||||
import { fetchSources } from "@portal/api/sources";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
|
||||
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
|
||||
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
|
||||
import "@portal/views/Policies.css";
|
||||
@@ -47,12 +48,8 @@ interface PolicySetupWizardProps {
|
||||
|
||||
type Step = "workflow" | "settings";
|
||||
|
||||
/** A configurable tool in the workflow step: whether it runs + its params. */
|
||||
interface ToolState {
|
||||
operation: string;
|
||||
enabled: boolean;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
/** A policy step plus whether it runs. */
|
||||
type ToolState = PolicyToolStep & { enabled: boolean };
|
||||
|
||||
/** Resolve each field's effective value: saved override, else definition default. */
|
||||
function resolveFieldValues(
|
||||
@@ -69,9 +66,8 @@ function resolveFieldValues(
|
||||
* round-trips); otherwise the category preset's default chain. Each preset step
|
||||
* starts enabled — the user toggles tools off in the workflow.
|
||||
*/
|
||||
// Temporary: tracks which tools start disabled until the tool registry lands in
|
||||
// the portal and can drive this via registry metadata or a defaultEnabled flag.
|
||||
const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]);
|
||||
// Temporary until the catalogue carries a defaultEnabled flag.
|
||||
const DISABLED_BY_DEFAULT = new Set<PolicyToolId>(["watermark"]);
|
||||
|
||||
/**
|
||||
* Policy-facing framing for each capability a policy can include. Labels and
|
||||
@@ -81,43 +77,43 @@ const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]);
|
||||
* the humanised endpoint name with no description.
|
||||
*/
|
||||
const CAPABILITY_META: Record<
|
||||
string,
|
||||
PolicyToolId,
|
||||
{ labelKey: string; labelEn: string; descKey: string; descEn: string }
|
||||
> = {
|
||||
[TOOL_ENDPOINTS.redact]: {
|
||||
redact: {
|
||||
labelKey: "portal.policies.wizard.capability.redact.label",
|
||||
labelEn: "Redact sensitive information",
|
||||
descKey: "portal.policies.wizard.capability.redact.desc",
|
||||
descEn:
|
||||
"Finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read.",
|
||||
},
|
||||
[TOOL_ENDPOINTS.sanitize]: {
|
||||
sanitize: {
|
||||
labelKey: "portal.policies.wizard.capability.sanitize.label",
|
||||
labelEn: "Strip active content",
|
||||
descKey: "portal.policies.wizard.capability.sanitize.desc",
|
||||
descEn:
|
||||
"Removes hidden JavaScript so nothing can run automatically when the document is opened.",
|
||||
},
|
||||
[TOOL_ENDPOINTS.watermark]: {
|
||||
watermark: {
|
||||
labelKey: "portal.policies.wizard.capability.watermark.label",
|
||||
labelEn: "Apply a watermark",
|
||||
descKey: "portal.policies.wizard.capability.watermark.desc",
|
||||
descEn: "Stamps a visible mark (e.g. “Confidential”) across every page.",
|
||||
},
|
||||
[TOOL_ENDPOINTS.ocr]: {
|
||||
ocr: {
|
||||
labelKey: "portal.policies.wizard.capability.ocr.label",
|
||||
labelEn: "Make text searchable",
|
||||
descKey: "portal.policies.wizard.capability.ocr.desc",
|
||||
descEn: "Runs OCR so scanned pages become selectable, searchable text.",
|
||||
},
|
||||
[TOOL_ENDPOINTS.flatten]: {
|
||||
flatten: {
|
||||
labelKey: "portal.policies.wizard.capability.flatten.label",
|
||||
labelEn: "Flatten the document",
|
||||
descKey: "portal.policies.wizard.capability.flatten.desc",
|
||||
descEn:
|
||||
"Merges form fields and annotations into the page so they can't be edited.",
|
||||
},
|
||||
[TOOL_ENDPOINTS.compress]: {
|
||||
compress: {
|
||||
labelKey: "portal.policies.wizard.capability.compress.label",
|
||||
labelEn: "Reduce file size",
|
||||
descKey: "portal.policies.wizard.capability.compress.desc",
|
||||
@@ -125,29 +121,24 @@ const CAPABILITY_META: Record<
|
||||
},
|
||||
};
|
||||
|
||||
function seedTools(
|
||||
entry: CatalogueEntry,
|
||||
registry: Partial<ToolRegistry>,
|
||||
): ToolState[] {
|
||||
function seedTools(entry: CatalogueEntry): ToolState[] {
|
||||
const savedSteps = entry.policy?.steps ?? [];
|
||||
const savedByOp = new Map(savedSteps.map((s) => [s.operation, s]));
|
||||
// Always use defaultOperations as the canonical list so tools added after a
|
||||
// policy was first saved still appear when editing.
|
||||
return entry.config.defaultOperations.map((s) => {
|
||||
const saved = savedByOp.get(s.operation);
|
||||
const savedByTool = new Map<PolicyToolId, PolicyToolStep>();
|
||||
for (const wire of savedSteps) {
|
||||
const step = policyStepFromWire(wire);
|
||||
if (step) savedByTool.set(step.toolId, step);
|
||||
}
|
||||
// defaultOperations is the canonical list (so tools added later still show on edit); a saved
|
||||
// step's params win over the preset.
|
||||
return entry.config.defaultOperations.map((preset) => {
|
||||
const saved = savedByTool.get(preset.toolId);
|
||||
return {
|
||||
operation: s.operation,
|
||||
...(saved ?? preset),
|
||||
enabled: saved
|
||||
? true
|
||||
: savedSteps.length > 0
|
||||
? false
|
||||
: !DISABLED_BY_DEFAULT.has(s.operation),
|
||||
// Saved steps are in the backend contract shape; map them back to the UI
|
||||
// shape the config controls edit (e.g. `listOfText` -> `wordsToRedact`).
|
||||
// Presets are already authored in the UI shape, so use them as-is.
|
||||
parameters: saved
|
||||
? deserializeToolStep(saved, registry).params
|
||||
: s.parameters,
|
||||
: !DISABLED_BY_DEFAULT.has(preset.toolId),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -185,26 +176,12 @@ function PolicySetupWizardBody({
|
||||
onSubmit: (entry: CatalogueEntry, result: PolicySetupResult) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { allTools: toolRegistry } = useToolRegistry();
|
||||
|
||||
// Portal tool operations are endpoint paths (/api/v1/…), not short registry IDs.
|
||||
// Build a reverse map so we can look up icons and display names by endpoint.
|
||||
const registryByEndpoint = useMemo(() => {
|
||||
const map = new Map<string, ToolRegistryEntry>();
|
||||
for (const entry of Object.values(toolRegistry)) {
|
||||
const ep = (entry as ToolRegistryEntry).operationConfig?.endpoint;
|
||||
if (typeof ep === "string") map.set(ep, entry as ToolRegistryEntry);
|
||||
}
|
||||
return map;
|
||||
}, [toolRegistry]);
|
||||
|
||||
const { category, config, policy } = entry;
|
||||
const isEdit = policy != null;
|
||||
|
||||
const [step, setStep] = useState<Step>("workflow");
|
||||
const [tools, setTools] = useState<ToolState[]>(() =>
|
||||
seedTools(entry, toolRegistry),
|
||||
);
|
||||
const [tools, setTools] = useState<ToolState[]>(() => seedTools(entry));
|
||||
const [fieldValues, setFieldValues] = useState(() =>
|
||||
resolveFieldValues(entry),
|
||||
);
|
||||
@@ -213,22 +190,11 @@ function PolicySetupWizardBody({
|
||||
);
|
||||
|
||||
const sourcesAsync = useAsync(() => fetchSources(), []);
|
||||
const availableSources = useMemo(() => {
|
||||
const backendSources = (sourcesAsync.data?.sources ?? []).filter(
|
||||
(s) => s.status !== "disabled",
|
||||
);
|
||||
const editorSource = {
|
||||
id: "editor",
|
||||
name: t("portal.sources.types.editor.label"),
|
||||
type: "editor",
|
||||
status: "active" as const,
|
||||
referenceCount: 0,
|
||||
referencingPolicies: [],
|
||||
config: [],
|
||||
docsTotal: null,
|
||||
};
|
||||
return [editorSource, ...backendSources];
|
||||
}, [sourcesAsync.data, t]);
|
||||
const availableSources = useMemo(
|
||||
() =>
|
||||
(sourcesAsync.data?.sources ?? []).filter((s) => s.status !== "disabled"),
|
||||
[sourcesAsync.data],
|
||||
);
|
||||
// Document-type scoping has no UI; preserve any saved scope on edit and
|
||||
// default new policies to all document types.
|
||||
const [scopeTypes] = useState<string[]>(policy?.state.scopeTypes ?? []);
|
||||
@@ -256,9 +222,20 @@ function PolicySetupWizardBody({
|
||||
|
||||
const enabledTools = useMemo(() => tools.filter((tl) => tl.enabled), [tools]);
|
||||
|
||||
function patchTool(operation: string, patch: Partial<ToolState>) {
|
||||
function setToolEnabled(toolId: PolicyToolId, enabled: boolean) {
|
||||
setTools((prev) =>
|
||||
prev.map((tl) => (tl.operation === operation ? { ...tl, ...patch } : tl)),
|
||||
prev.map((tl) => (tl.toolId === toolId ? { ...tl, enabled } : tl)),
|
||||
);
|
||||
}
|
||||
|
||||
function setToolParams<Id extends PolicyToolId>(
|
||||
toolId: Id,
|
||||
params: PolicyParams<Id>,
|
||||
) {
|
||||
setTools((prev) =>
|
||||
prev.map((tl) =>
|
||||
tl.toolId === toolId ? ({ ...tl, params } as ToolState) : tl,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -277,11 +254,8 @@ function PolicySetupWizardBody({
|
||||
}
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
// Map each tool's UI-shaped params (e.g. redact's `wordsToRedact`) into the
|
||||
// backend step contract (e.g. `listOfText`) via its `toApiParams`; saving the
|
||||
// UI shape verbatim would drop those fields and the step would run with none.
|
||||
const steps: PipelineStep[] = enabledTools.map((tl) =>
|
||||
serializeStepFromEndpoint(tl.operation, tl.parameters, toolRegistry),
|
||||
policyStepToWire(tl),
|
||||
);
|
||||
try {
|
||||
await onSubmit(entry, {
|
||||
@@ -386,20 +360,16 @@ function PolicySetupWizardBody({
|
||||
<Card padding="none">
|
||||
<div className="portal-policies__capabilities">
|
||||
{tools.map((tl) => {
|
||||
const meta = CAPABILITY_META[tl.operation];
|
||||
const meta = CAPABILITY_META[tl.toolId];
|
||||
const label = meta
|
||||
? t(meta.labelKey, meta.labelEn)
|
||||
: (registryByEndpoint.get(tl.operation)?.name ??
|
||||
humanizeEndpoint(tl.operation, t));
|
||||
: humanizeEndpoint(policyEndpoint(tl.toolId), t);
|
||||
const description = meta
|
||||
? t(meta.descKey, meta.descEn)
|
||||
: undefined;
|
||||
const hasConfig =
|
||||
tl.operation === TOOL_ENDPOINTS.redact ||
|
||||
tl.operation === TOOL_ENDPOINTS.watermark;
|
||||
return (
|
||||
<div
|
||||
key={tl.operation}
|
||||
key={tl.toolId}
|
||||
className="portal-policies__capability"
|
||||
data-on={tl.enabled || undefined}
|
||||
>
|
||||
@@ -411,27 +381,27 @@ function PolicySetupWizardBody({
|
||||
size="sm"
|
||||
checked={tl.enabled}
|
||||
onChange={(checked) =>
|
||||
patchTool(tl.operation, { enabled: checked })
|
||||
setToolEnabled(tl.toolId, checked)
|
||||
}
|
||||
label=""
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{tl.enabled && hasConfig && (
|
||||
{tl.enabled && (
|
||||
<div className="portal-policies__capability-config">
|
||||
{tl.operation === TOOL_ENDPOINTS.redact && (
|
||||
{tl.toolId === "redact" && (
|
||||
<PolicyRedactConfig
|
||||
parameters={tl.parameters}
|
||||
onChange={(parameters) =>
|
||||
patchTool(tl.operation, { parameters })
|
||||
parameters={tl.params}
|
||||
onChange={(params) =>
|
||||
setToolParams("redact", params)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{tl.operation === TOOL_ENDPOINTS.watermark && (
|
||||
{tl.toolId === "watermark" && (
|
||||
<PolicyWatermarkConfig
|
||||
parameters={tl.parameters}
|
||||
onChange={(parameters) =>
|
||||
patchTool(tl.operation, { parameters })
|
||||
parameters={tl.params}
|
||||
onChange={(params) =>
|
||||
setToolParams("watermark", params)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -475,9 +445,8 @@ function PolicySetupWizardBody({
|
||||
{t("portal.policies.wizard.sources.loading")}
|
||||
</p>
|
||||
) : (
|
||||
// The editor is always an available source (unconditionally prepended
|
||||
// to availableSources), so the list is never empty — no "no sources"
|
||||
// state exists.
|
||||
// The backend always returns the editor as a virtual source, so the
|
||||
// loaded list is never empty - no "no sources" state exists.
|
||||
<div className="portal-policies__sources">
|
||||
{availableSources.map((src) => (
|
||||
// A selectable multi-line tile (icon + name + type + check).
|
||||
|
||||
@@ -75,7 +75,7 @@ export function DocumentLedger({
|
||||
className="portal-proc__stage-label"
|
||||
data-current={cur || undefined}
|
||||
>
|
||||
{group.label}
|
||||
{t(group.label)}
|
||||
</span>
|
||||
{blurb && (
|
||||
<span className="portal-proc__stage-hint">
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ConnectWizard } from "@portal/components/sources/ConnectWizard";
|
||||
|
||||
const meta: Meta<typeof ConnectWizard> = {
|
||||
title: "Portal/Sources/ConnectWizard",
|
||||
component: ConnectWizard,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: { open: true, onClose: () => {}, onCreated: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ConnectWizard>;
|
||||
|
||||
/** Opens on the type-picker step; Continue/Back walk through the three steps. */
|
||||
export const Open: Story = {};
|
||||
|
||||
export const Closed: Story = { args: { open: false } };
|
||||
@@ -1,192 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { HttpError } from "@portal/api/http";
|
||||
import { ConnectWizard } from "@portal/components/sources/ConnectWizard";
|
||||
|
||||
function renderWithMantine(ui: React.ReactElement) {
|
||||
return render(<MantineProvider>{ui}</MantineProvider>);
|
||||
}
|
||||
|
||||
// Deterministic i18n: keys come back verbatim so the test never waits on the
|
||||
// async TOML backend.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the service layer so no real fetch is issued.
|
||||
const createSource = vi.fn();
|
||||
vi.mock("@portal/api/sources", () => ({
|
||||
createSource: (...args: unknown[]) => createSource(...args),
|
||||
}));
|
||||
|
||||
/** Step through the folder-source flow: choose type -> configure -> review. */
|
||||
function stepToReview() {
|
||||
// Step 0: folder is the default-selected type. Continue.
|
||||
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
|
||||
|
||||
// Step 1: fill the required name + directory fields.
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
// First textbox is the name field, second is the folder's directory field.
|
||||
fireEvent.change(inputs[0], { target: { value: "Claims intake" } });
|
||||
fireEvent.change(inputs[1], { target: { value: "/data/incoming" } });
|
||||
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
|
||||
}
|
||||
|
||||
describe("ConnectWizard", () => {
|
||||
beforeEach(() => {
|
||||
createSource.mockReset();
|
||||
});
|
||||
|
||||
it("creates a folder source with the configured options", async () => {
|
||||
createSource.mockResolvedValue({ id: "src-1" });
|
||||
const onCreated = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
renderWithMantine(
|
||||
<ConnectWizard open onClose={onClose} onCreated={onCreated} />,
|
||||
);
|
||||
|
||||
stepToReview();
|
||||
|
||||
// Step 2: submit via the final "connect source" action.
|
||||
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(createSource).toHaveBeenCalledWith({
|
||||
name: "Claims intake",
|
||||
type: "folder",
|
||||
options: {
|
||||
directory: "/data/incoming",
|
||||
mode: "consume",
|
||||
recursive: "false",
|
||||
identity: "stat",
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onCreated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("creates an s3 source with a masked secret in review", async () => {
|
||||
createSource.mockResolvedValue({ id: "src-2" });
|
||||
|
||||
renderWithMantine(
|
||||
<ConnectWizard open onClose={vi.fn()} onCreated={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Step 0: pick the S3 type card.
|
||||
fireEvent.click(screen.getByText("portal.sources.types.s3.label"));
|
||||
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
|
||||
|
||||
// Step 1: name, bucket, credentials. The secret renders as a password
|
||||
// input, so it is not part of the textbox roles.
|
||||
const inputs = screen.getAllByRole("textbox") as HTMLInputElement[];
|
||||
fireEvent.change(inputs[0], { target: { value: "Claims bucket" } });
|
||||
fireEvent.change(inputs[1], { target: { value: "claims-inbox" } });
|
||||
fireEvent.change(inputs[4], { target: { value: "AKIAEXAMPLE" } });
|
||||
const secret = document.querySelector(
|
||||
'input[type="password"]',
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(secret, { target: { value: "shh-secret" } });
|
||||
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
|
||||
|
||||
// Step 2: the secret is masked in review, never echoed.
|
||||
expect(screen.queryByText("shh-secret")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("********")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
|
||||
await waitFor(() => {
|
||||
expect(createSource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(createSource).toHaveBeenCalledWith({
|
||||
name: "Claims bucket",
|
||||
type: "s3",
|
||||
options: {
|
||||
bucket: "claims-inbox",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
accessKeyId: "AKIAEXAMPLE",
|
||||
secretAccessKey: "shh-secret",
|
||||
endpoint: "",
|
||||
mode: "consume",
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("edits an existing source: prefilled, skips type, submits with its id", async () => {
|
||||
createSource.mockResolvedValue({ id: "s1" });
|
||||
const onCreated = vi.fn();
|
||||
|
||||
renderWithMantine(
|
||||
<ConnectWizard
|
||||
open
|
||||
source={{
|
||||
id: "s1",
|
||||
name: "James",
|
||||
type: "folder",
|
||||
options: { directory: "'/data/in'", mode: "consume" },
|
||||
enabled: true,
|
||||
}}
|
||||
onClose={vi.fn()}
|
||||
onCreated={onCreated}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Edit opens on the configure step with name + directory prefilled.
|
||||
const inputs = screen.getAllByRole("textbox") as HTMLInputElement[];
|
||||
expect(inputs[0].value).toBe("James");
|
||||
expect(inputs[1].value).toBe("'/data/in'");
|
||||
|
||||
// Fix the stray quotes, continue to review, save.
|
||||
fireEvent.change(inputs[1], { target: { value: "/data/in" } });
|
||||
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
|
||||
fireEvent.click(screen.getByText("portal.sources.wizard.save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// Options absent from the stored source are submitted at their defaults.
|
||||
expect(createSource).toHaveBeenCalledWith({
|
||||
id: "s1",
|
||||
name: "James",
|
||||
type: "folder",
|
||||
options: {
|
||||
directory: "/data/in",
|
||||
mode: "consume",
|
||||
recursive: "false",
|
||||
identity: "stat",
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onCreated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the inline error message when create fails", async () => {
|
||||
createSource.mockRejectedValue(
|
||||
new HttpError(400, "Bad Request", {
|
||||
detail: "Directory is not readable",
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithMantine(
|
||||
<ConnectWizard open onClose={vi.fn()} onCreated={vi.fn()} />,
|
||||
);
|
||||
|
||||
stepToReview();
|
||||
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Directory is not readable"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,298 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
FormField,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
StatTile,
|
||||
} from "@app/ui";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import { createSource, type Source } from "@portal/api/sources";
|
||||
import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes";
|
||||
import {
|
||||
defaultOptions,
|
||||
sourceTypeMeta,
|
||||
type CreatableSourceType,
|
||||
} from "@portal/components/sources/sourceTypes";
|
||||
import "@portal/views/Sources.css";
|
||||
|
||||
const OFFERED_TYPES = creatableSourceTypes();
|
||||
const DEFAULT_TYPE = OFFERED_TYPES[0];
|
||||
|
||||
/** Wizard steps. Editing skips type selection (the type is fixed once created). */
|
||||
type StepId = "type" | "configure" | "review";
|
||||
const CREATE_STEPS: StepId[] = ["type", "configure", "review"];
|
||||
const EDIT_STEPS: StepId[] = ["configure", "review"];
|
||||
|
||||
interface ConnectWizardProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Called after a source is created or updated so the page can refetch. */
|
||||
onCreated: () => void;
|
||||
/** When set, the wizard edits this existing source instead of creating one. */
|
||||
source?: Source;
|
||||
}
|
||||
|
||||
/** The creatable-type metadata for a source's stored type, falling back to the first offered. */
|
||||
function typeFor(type: string | undefined): CreatableSourceType {
|
||||
return OFFERED_TYPES.find((t) => t.type === type) ?? DEFAULT_TYPE;
|
||||
}
|
||||
|
||||
/** Source options coerced to strings for the form, defaulted from the type's fields. */
|
||||
function optionsFor(
|
||||
type: CreatableSourceType,
|
||||
options: Record<string, unknown> | undefined,
|
||||
): Record<string, string> {
|
||||
const out = defaultOptions(type);
|
||||
for (const [key, value] of Object.entries(options ?? {})) {
|
||||
out[key] = value == null ? "" : String(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guided flow for connecting a source (choose type -> configure -> review) or
|
||||
* editing an existing one (configure -> review, type fixed). On submit a blank
|
||||
* id creates and a set id updates, matching the backend's POST contract.
|
||||
*/
|
||||
export function ConnectWizard({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
source,
|
||||
}: ConnectWizardProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = source !== undefined;
|
||||
const steps = isEdit ? EDIT_STEPS : CREATE_STEPS;
|
||||
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [type, setType] = useState<CreatableSourceType>(() =>
|
||||
typeFor(source?.type),
|
||||
);
|
||||
const [name, setName] = useState(source?.name ?? "");
|
||||
const [options, setOptions] = useState<Record<string, string>>(() =>
|
||||
optionsFor(typeFor(source?.type), source?.options),
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Re-seed the form whenever the wizard opens (or its target source changes) so
|
||||
// editing prefills the current config and a reopened create starts clean.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const ct = typeFor(source?.type);
|
||||
setStepIndex(0);
|
||||
setType(ct);
|
||||
setName(source?.name ?? "");
|
||||
setOptions(optionsFor(ct, source?.options));
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
}, [open, source]);
|
||||
|
||||
const stepId = steps[stepIndex];
|
||||
const isLast = stepIndex === steps.length - 1;
|
||||
|
||||
function chooseType(next: CreatableSourceType) {
|
||||
setType(next);
|
||||
setOptions(defaultOptions(next));
|
||||
}
|
||||
|
||||
const requiredFilled = type.fields.every(
|
||||
(f) => !f.required || (options[f.key] ?? "").trim() !== "",
|
||||
);
|
||||
const canContinue =
|
||||
stepId === "configure" ? name.trim() !== "" && requiredFilled : true;
|
||||
|
||||
async function advance() {
|
||||
if (!isLast) {
|
||||
setStepIndex((i) => i + 1);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fields = {
|
||||
name: name.trim(),
|
||||
type: type.type,
|
||||
options,
|
||||
enabled: source?.enabled ?? true,
|
||||
};
|
||||
await createSource(isEdit ? { ...fields, id: source.id } : fields);
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const stepLabels: Record<StepId, string> = {
|
||||
type: t("portal.sources.wizard.steps.chooseType"),
|
||||
configure: t("portal.sources.wizard.steps.configure"),
|
||||
review: t("portal.sources.wizard.steps.review"),
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="lg"
|
||||
title={
|
||||
isEdit
|
||||
? t("portal.sources.wizard.editTitle")
|
||||
: t("portal.sources.wizard.title")
|
||||
}
|
||||
subtitle={t("portal.sources.wizard.subtitle", {
|
||||
current: stepIndex + 1,
|
||||
total: steps.length,
|
||||
label: stepLabels[stepId],
|
||||
})}
|
||||
footer={
|
||||
<div className="portal-sources__wizard-footer">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={submitting}
|
||||
onClick={() =>
|
||||
stepIndex === 0 ? onClose() : setStepIndex((i) => i - 1)
|
||||
}
|
||||
>
|
||||
{stepIndex === 0
|
||||
? t("portal.sources.wizard.cancel")
|
||||
: t("portal.sources.wizard.back")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={advance}
|
||||
loading={submitting}
|
||||
disabled={!canContinue}
|
||||
rightSection={!isLast ? <span aria-hidden>→</span> : undefined}
|
||||
>
|
||||
{!isLast
|
||||
? t("portal.sources.wizard.continue")
|
||||
: isEdit
|
||||
? t("portal.sources.wizard.save")
|
||||
: t("portal.sources.actions.connectSource")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ol className="portal-sources__steps" aria-hidden>
|
||||
{steps.map((id, i) => (
|
||||
<li
|
||||
key={id}
|
||||
className={
|
||||
"portal-sources__step" +
|
||||
(i === stepIndex ? " is-active" : i < stepIndex ? " is-done" : "")
|
||||
}
|
||||
>
|
||||
<span className="portal-sources__step-mark">
|
||||
{i < stepIndex ? "✓" : i + 1}
|
||||
</span>
|
||||
{stepLabels[id]}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{stepId === "type" && (
|
||||
<div className="portal-sources__type-grid">
|
||||
{OFFERED_TYPES.map((ct) => (
|
||||
<Button
|
||||
key={ct.type}
|
||||
variant="tertiary"
|
||||
className={
|
||||
"portal-sources__type-card" +
|
||||
(type.type === ct.type ? " is-selected" : "")
|
||||
}
|
||||
onClick={() => chooseType(ct)}
|
||||
>
|
||||
<span className="portal-sources__type-icon" aria-hidden>
|
||||
{sourceTypeMeta(ct.type).icon}
|
||||
</span>
|
||||
<span className="portal-sources__type-name">
|
||||
{t(ct.labelKey)}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stepId === "configure" && (
|
||||
<div className="portal-sources__wizard-body">
|
||||
<FormField label={t("portal.sources.wizard.name")} required>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder={t("portal.sources.wizard.namePlaceholder")}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
{type.fields.map((field) => (
|
||||
<FormField
|
||||
key={field.key}
|
||||
label={t(field.labelKey)}
|
||||
helperText={
|
||||
field.helperTextKey ? t(field.helperTextKey) : undefined
|
||||
}
|
||||
required={field.required}
|
||||
>
|
||||
{field.control === "select" ? (
|
||||
<Select
|
||||
value={options[field.key] ?? ""}
|
||||
options={(field.options ?? []).map((o) => ({
|
||||
value: o.value,
|
||||
label: t(o.labelKey),
|
||||
}))}
|
||||
onChange={(value) =>
|
||||
setOptions((o) => ({ ...o, [field.key]: value ?? "" }))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.control === "password" ? "password" : undefined}
|
||||
value={options[field.key] ?? ""}
|
||||
placeholder={
|
||||
field.placeholderKey ? t(field.placeholderKey) : undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
setOptions((o) => ({ ...o, [field.key]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stepId === "review" && (
|
||||
<div className="portal-sources__wizard-body">
|
||||
<div className="portal-sources__stat-grid">
|
||||
<StatTile
|
||||
label={t("portal.sources.wizard.name")}
|
||||
value={name || "—"}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.sources.wizard.type")}
|
||||
value={t(type.labelKey)}
|
||||
/>
|
||||
{type.fields.map((field) => (
|
||||
<StatTile
|
||||
key={field.key}
|
||||
label={t(field.labelKey)}
|
||||
value={
|
||||
field.control === "password" && options[field.key]
|
||||
? "********"
|
||||
: options[field.key] || "—"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fireEvent,
|
||||
render as baseRender,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { HttpError } from "@portal/api/http";
|
||||
import { ConnectionsTab } from "@portal/components/sources/ConnectionsTab";
|
||||
import type { IntegrationConfig } from "@portal/api/integrations";
|
||||
|
||||
const render = (ui: Parameters<typeof baseRender>[0]) =>
|
||||
baseRender(ui, { wrapper: MantineProvider });
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
const fetchS3Connections = vi.fn();
|
||||
const deleteIntegration = vi.fn();
|
||||
vi.mock("@portal/api/integrations", () => ({
|
||||
fetchS3Connections: () => fetchS3Connections(),
|
||||
deleteIntegration: (id: number) => deleteIntegration(id),
|
||||
createIntegration: vi.fn(),
|
||||
updateIntegration: vi.fn(),
|
||||
}));
|
||||
|
||||
const CONNECTION = {
|
||||
id: 5,
|
||||
integrationType: "S3",
|
||||
name: "Claims bucket",
|
||||
config: { bucket: "inbox", region: "us-east-1" },
|
||||
canManage: true,
|
||||
} as unknown as IntegrationConfig;
|
||||
|
||||
describe("ConnectionsTab", () => {
|
||||
beforeEach(() => {
|
||||
fetchS3Connections.mockReset();
|
||||
deleteIntegration.mockReset();
|
||||
deleteIntegration.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no connections", async () => {
|
||||
fetchS3Connections.mockResolvedValue([]);
|
||||
render(<ConnectionsTab />);
|
||||
expect(
|
||||
await screen.findByText("portal.connections.empty.title"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists connections and deletes one", async () => {
|
||||
fetchS3Connections.mockResolvedValueOnce([CONNECTION]);
|
||||
fetchS3Connections.mockResolvedValueOnce([]);
|
||||
render(<ConnectionsTab />);
|
||||
|
||||
expect(await screen.findByText("Claims bucket")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("portal.connections.delete"));
|
||||
await waitFor(() => expect(deleteIntegration).toHaveBeenCalledWith(5));
|
||||
});
|
||||
|
||||
it("surfaces the 409 when deleting a connection still in use", async () => {
|
||||
fetchS3Connections.mockResolvedValue([CONNECTION]);
|
||||
deleteIntegration.mockRejectedValue(
|
||||
new HttpError(409, "Conflict", {
|
||||
detail: "Integration is in use by: source 'Claims intake'",
|
||||
}),
|
||||
);
|
||||
render(<ConnectionsTab />);
|
||||
|
||||
await screen.findByText("Claims bucket");
|
||||
fireEvent.click(screen.getByText("portal.connections.delete"));
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Integration is in use by: source 'Claims intake'",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AddRoundedIcon from "@mui/icons-material/AddRounded";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@app/ui";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import {
|
||||
deleteIntegration,
|
||||
fetchS3Connections,
|
||||
type IntegrationConfig,
|
||||
} from "@portal/api/integrations";
|
||||
import { SourcesIcon } from "@portal/components/icons";
|
||||
import { S3ConnectionModal } from "@portal/components/sources/S3ConnectionModal";
|
||||
|
||||
/**
|
||||
* The Connections tab of the Sources page: stored S3 connections that sources
|
||||
* and pipeline outputs reference by id. Create/edit go through the shared
|
||||
* {@link S3ConnectionModal}; deleting one the backend still references returns a
|
||||
* 409, surfaced inline.
|
||||
*/
|
||||
export function ConnectionsTab() {
|
||||
const { t } = useTranslation();
|
||||
const [connections, setConnections] = useState<IntegrationConfig[] | null>(
|
||||
null,
|
||||
);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<IntegrationConfig | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setConnections(await fetchS3Connections());
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(connection: IntegrationConfig) {
|
||||
setEditing(connection);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function remove(connection: IntegrationConfig) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteIntegration(connection.id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<TableColumn<IntegrationConfig>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: t("portal.connections.table.name"),
|
||||
render: (c) => <strong>{c.name}</strong>,
|
||||
},
|
||||
{
|
||||
key: "bucket",
|
||||
header: t("portal.connections.table.bucket"),
|
||||
render: (c) => (
|
||||
<span className="portal-sources__connections-bucket">
|
||||
{String(c.config?.bucket ?? "")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
header: t("portal.connections.table.region"),
|
||||
render: (c) => String(c.config?.region ?? ""),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
render: (c) =>
|
||||
c.canManage ? (
|
||||
<span className="portal-sources__connections-actions">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => openEdit(c)}
|
||||
>
|
||||
{t("portal.connections.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
accent="danger"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(c)}
|
||||
>
|
||||
{t("portal.connections.delete")}
|
||||
</Button>
|
||||
</span>
|
||||
) : null,
|
||||
},
|
||||
],
|
||||
// remove/openEdit are stable enough for this admin surface; busy gates them.
|
||||
[t, busy],
|
||||
);
|
||||
|
||||
const isLoading = connections === null;
|
||||
const isEmpty = connections !== null && connections.length === 0;
|
||||
|
||||
return (
|
||||
<section className="portal-sources__connections">
|
||||
<div className="portal-sources__connections-head">
|
||||
<p className="portal-sources__connections-sub">
|
||||
{t("portal.connections.subtitle")}
|
||||
</p>
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
|
||||
>
|
||||
{t("portal.connections.actions.new")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-sources__table-skeleton" aria-hidden>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} height="3rem" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
icon={<SourcesIcon size={28} />}
|
||||
title={t("portal.connections.empty.title")}
|
||||
description={t("portal.connections.empty.description")}
|
||||
actions={
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
|
||||
>
|
||||
{t("portal.connections.actions.new")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{connections !== null && connections.length > 0 && (
|
||||
<Table<IntegrationConfig>
|
||||
className="portal-sources__connections-table"
|
||||
columns={columns}
|
||||
rows={connections}
|
||||
rowKey={(c) => String(c.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<S3ConnectionModal
|
||||
open={modalOpen}
|
||||
connection={editing}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={() => void refresh()}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user