Compare commits

..
Author SHA1 Message Date
Frooodle bb37d5ae07 Merge branch 'main' into security/server-image-cves 2026-07-13 09:24:04 +01:00
Frooodle e7a81092c5 merge main 2026-07-10 10:27:03 +01:00
Frooodle e8f76aa2d9 merge main 2026-07-06 18:54:14 +01:00
Frooodle ae450fa878 merge main 2026-07-02 09:44:37 +01:00
Frooodle 4a792c54ef merge main 2026-07-01 08:50:20 +01:00
Frooodle ecb1700c08 merge main 2026-06-30 08:39:35 +01:00
Frooodle fc99de2d53 merge main 2026-06-29 11:23:47 +01:00
Frooodle e099e0302b merge main 2026-06-27 08:44:15 +01:00
Anthony Stirling 6764cf1eff Patch CVEs in server image deps and base image 2026-06-25 10:30:46 +01:00
112 changed files with 1716 additions and 5443 deletions
@@ -254,9 +254,6 @@ public class ApplicationProperties {
* in-network object store.
*/
private boolean allowPrivateS3Endpoints = false;
/** Max inbound webhook body (bytes); over it is rejected 413. Default 100 MB. */
private long webhookMaxBytes = 104857600L;
}
@Data
@@ -198,8 +198,6 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/readiness")
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
// Webhook receiver: authenticated per-request by HMAC signature, not a session.
|| trimmedUri.startsWith("/api/v1/webhooks/")
|| trimmedUri.startsWith("/v1/api-docs")
// Workflow participant endpoints - access controlled by share tokens, not login
|| trimmedUri.startsWith("/api/v1/workflow/participant/")
@@ -176,13 +176,6 @@ class RequestUriUtilsTest {
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/convert", ""));
}
@Test
void testIsPublicAuthEndpoint_webhookReceiver() {
// The webhook source receiver authenticates each delivery by HMAC signature, not a session.
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/api/v1/webhooks/whk_abc123", ""));
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/api/v1/webhooks/whk_abc123", "/app"));
}
@Test
void testIsPublicAuthEndpoint_withContextPath() {
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/login", "/app"));
@@ -43,10 +43,6 @@ 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 ----
@@ -70,21 +66,13 @@ 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,
ownerTeamId,
request.ownerTeamId(),
currentUser,
() -> lockedServerExists(cfg.getIntegrationType()));
Map<String, Object> config = secretMasker.sanitize(request.config());
validateConfig(cfg.getIntegrationType(), config);
cfg.setConfig(writeJson(config));
cfg.setConfig(writeJson(secretMasker.sanitize(request.config())));
return repository.save(cfg);
}
@@ -113,10 +101,8 @@ public class IntegrationConfigService {
cfg.setDefaultAccess(request.defaultAccess());
}
if (request.config() != null) {
Map<String, Object> merged =
secretMasker.merge(readJson(cfg.getConfig()), request.config());
validateConfig(cfg.getIntegrationType(), merged);
cfg.setConfig(writeJson(merged));
cfg.setConfig(
writeJson(secretMasker.merge(readJson(cfg.getConfig()), request.config())));
}
return repository.save(cfg);
}
@@ -127,15 +113,6 @@ 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);
@@ -211,19 +188,6 @@ 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()
@@ -1,15 +0,0 @@
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);
}
@@ -1,23 +0,0 @@
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);
}
@@ -120,7 +120,6 @@ public class PolicyController {
throws IOException {
stampPolicyAudit(definition);
requireRunnable(definition);
validateAdHocOutput(definition);
PolicyInputs inputs = toInputs(files);
PolicyRunHandle handle =
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP);
@@ -141,7 +140,6 @@ public class PolicyController {
throws IOException {
stampPolicyAudit(definition);
requireRunnable(definition);
validateAdHocOutput(definition);
PolicyInputs inputs = toInputs(files);
SseEmitter emitter =
@@ -532,24 +530,6 @@ 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
@@ -52,19 +52,7 @@ public class PolicyValidator {
InputSpec spec = source.toInputSpec();
inputSourceFor(spec).validate(spec);
}
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);
outputSinkFor(policy.output()).validate(policy.output());
}
private PolicyTrigger triggerFor(TriggerConfig config) {
@@ -3,7 +3,6 @@ package stirling.software.proprietary.policy.input;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import stirling.software.proprietary.policy.model.InputSpec;
@@ -23,12 +22,6 @@ public interface InputSource {
/** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */
default void validate(InputSpec spec) {}
/** Normalise a source's options before persistence (default no-op); must not mutate the arg. */
default Map<String, Object> prepareOptionsForSave(
Map<String, Object> options, boolean isCreate) {
return options;
}
/**
* Resolve the spec into zero or more units of work, each carrying one run's files and a
* completion hook. Empty list means nothing to run right now. Discovery is read-only - files
@@ -18,7 +18,6 @@ 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;
@@ -37,14 +36,15 @@ 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: "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.
* 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.
*/
@Slf4j
@Service
@@ -55,7 +55,6 @@ public class S3InputSource implements InputSource {
private static final String TYPE = "s3";
private final S3ConnectionPool connectionPool;
private final S3ConnectionResolver connectionResolver;
@Override
public String type() {
@@ -68,12 +67,12 @@ public class S3InputSource implements InputSource {
}
/**
* 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.
* Fails fast at save time: bad config shape, a private endpoint without the operator opt-in, or
* a bucket the supplied credentials cannot list.
*/
@Override
public void validate(InputSpec spec) {
S3Config config = connectionResolver.resolve(spec.options());
S3Config config = S3Config.from(spec.options());
try {
connectionPool.clientFor(config).listObjectsV2(listRequest(config).maxKeys(1).build());
} catch (SdkException e) {
@@ -90,7 +89,7 @@ public class S3InputSource implements InputSource {
@Override
public List<ResolvedInput> resolve(InputSpec spec, ResolveContext ctx) throws IOException {
S3Config config = connectionResolver.resolve(spec.options());
S3Config config = S3Config.from(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".
@@ -1,191 +0,0 @@
package stirling.software.proprietary.policy.input;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.FileReadinessChecker;
import stirling.software.proprietary.policy.ledger.FolderIdentities;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.webhook.WebhookConfig;
import stirling.software.proprietary.policy.webhook.WebhookIds;
import stirling.software.proprietary.policy.webhook.WebhookSpool;
/** Push source: deliveries staged to an S3 connection or the local spool, read via the ledger. */
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class WebhookInputSource implements InputSource {
static final String TYPE = "webhook";
private final WebhookSpool spool;
private final FileReadinessChecker readinessChecker;
// Connection-backed staging delegates to the S3 source; else the local spool.
private final S3InputSource s3InputSource;
@Override
public String type() {
return TYPE;
}
@Override
public boolean supports(InputSpec spec) {
return spec != null && TYPE.equals(spec.type());
}
@Override
public void validate(InputSpec spec) {
WebhookConfig config = WebhookConfig.from(spec.options());
if (config.usesConnection()) {
// Resolve with the caller present so save fails if they can't use the connection.
s3InputSource.validate(stagingSpec(config));
}
}
/** The webhook's durable staging as an {@code s3} input spec for {@link S3InputSource}. */
private static InputSpec stagingSpec(WebhookConfig config) {
return new InputSpec(
"s3",
Map.of(
WebhookConfig.CONNECTION_ID_OPTION,
config.connectionId(),
"prefix",
config.stagingPrefix(),
"mode",
config.mode()));
}
/** Mint the routing id + signing secret on create; an existing webhook is left untouched. */
@Override
public Map<String, Object> prepareOptionsForSave(
Map<String, Object> options, boolean isCreate) {
boolean hasId =
options.get(WebhookConfig.WEBHOOK_ID_OPTION) != null
&& !options.get(WebhookConfig.WEBHOOK_ID_OPTION).toString().isBlank();
if (!isCreate && hasId) {
return options;
}
Map<String, Object> prepared = new LinkedHashMap<>(options);
if (!hasId) {
prepared.put(WebhookConfig.WEBHOOK_ID_OPTION, WebhookIds.newWebhookId());
}
Object secret = prepared.get(WebhookConfig.SIGNING_SECRET_OPTION);
if (secret == null || secret.toString().isBlank()) {
prepared.put(WebhookConfig.SIGNING_SECRET_OPTION, WebhookIds.newSigningSecret());
}
return prepared;
}
@Override
public List<ResolvedInput> resolve(InputSpec spec, ResolveContext ctx) throws IOException {
WebhookConfig config = WebhookConfig.from(spec.options());
if (config.usesConnection()) {
// Durable staging read by the S3 source (no principal; the save-time check is trusted).
return s3InputSource.resolve(stagingSpec(config), ctx);
}
Path dir = spool.dirFor(config.webhookId());
if (!Files.isDirectory(dir)) {
// No deliveries yet: verifiably empty (a missing dir is normal here, not an error).
ctx.reportPresent(List.of());
return List.of();
}
Path canonicalDir = FolderIdentities.canonicalDir(dir);
List<Path> present = listFiles(dir);
if (config.snapshot()) {
List<ResolvedInput> work = new ArrayList<>();
for (Path file : present) {
if (readinessChecker.isReady(file)) {
work.add(ResolvedInput.of(PolicyInputs.of(List.of(fileResource(file)))));
}
}
return work;
}
ctx.reportPresent(
present.stream()
.map(file -> FolderIdentities.identity(canonicalDir, dir, file))
.toList());
List<ResolvedInput> work = new ArrayList<>();
for (Path file : present) {
if (!readinessChecker.isReady(file)) {
continue;
}
String identity = FolderIdentities.identity(canonicalDir, dir, file);
String gate;
boolean claimed;
try {
gate = FolderIdentities.statGate(file);
claimed = ctx.claim(identity, gate, null);
} catch (IOException | UncheckedIOException e) {
log.debug("Could not read {} for its version: {}", file, e.getMessage());
continue; // vanished or unreadable mid-sweep; the next sweep sees the truth
}
if (!claimed) {
continue;
}
work.add(
new ResolvedInput(
PolicyInputs.of(List.of(fileResource(file))),
success -> completeConsumed(ctx, identity, file, gate, success)));
}
return work;
}
/** Settle, then delete when unchanged and every claimant settled DONE (consensus). */
private static void completeConsumed(
ResolveContext ctx, String identity, Path file, String claimGate, boolean success) {
ctx.settle(identity, claimGate, null, success);
if (!success) {
return;
}
try {
if (FolderIdentities.statGate(file).equals(claimGate) && ctx.allSettledDone(identity)) {
Files.deleteIfExists(file);
}
} catch (java.nio.file.NoSuchFileException alreadyGone) {
// Removed by the user or a co-watching policy's own consensus delete: nothing to do.
} catch (IOException e) {
log.warn("Could not remove consumed webhook delivery {}: {}", file, e.getMessage());
}
}
/** Every non-hidden regular file currently spooled for the webhook. */
private static List<Path> listFiles(Path dir) throws IOException {
List<Path> files = new ArrayList<>();
try (Stream<Path> entries = Files.list(dir)) {
entries.filter(Files::isRegularFile)
.filter(file -> !file.getFileName().toString().startsWith("."))
.forEach(files::add);
}
return files;
}
private static Resource fileResource(Path path) {
String name = WebhookSpool.displayName(path.getFileName().toString());
return new FileSystemResource(path.toFile()) {
@Override
public String getFilename() {
return name;
}
};
}
}
@@ -27,7 +27,6 @@ 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;
@@ -60,7 +59,6 @@ public class S3OutputSink implements PolicyOutputSink {
private static final String TYPE = "s3";
private final S3ConnectionPool connectionPool;
private final S3ConnectionResolver connectionResolver;
private final ProcessedLedger processedLedger;
@Override
@@ -74,19 +72,19 @@ public class S3OutputSink implements PolicyOutputSink {
}
/**
* 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.
* 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.
*/
@Override
public void validate(OutputSpec spec) {
connectionPool.clientFor(connectionResolver.resolve(spec.options()));
connectionPool.clientFor(S3Config.from(spec.options()));
}
@Override
public List<ResultFile> deliver(
OutputDelivery delivery, List<Resource> outputs, OutputSpec spec) throws IOException {
S3Config config = connectionResolver.resolve(spec.options());
S3Config config = S3Config.from(spec.options());
S3Client client = connectionPool.clientFor(config);
List<ResultFile> results = new ArrayList<>();
@@ -1,212 +0,0 @@
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());
}
}
@@ -1,56 +0,0 @@
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,12 +5,10 @@ import java.net.URISyntaxException;
import java.util.Map;
/**
* 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.
* 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.
*/
public record S3Config(
String bucket,
@@ -1,151 +0,0 @@
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);
}
}
}
@@ -1,51 +0,0 @@
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);
}
}
}
@@ -2,7 +2,6 @@ package stirling.software.proprietary.policy.source;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.HttpStatus;
@@ -47,8 +46,6 @@ import stirling.software.proprietary.util.SecretMasker;
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class SourceController {
private static final String WEBHOOK_TYPE = "webhook";
private final SourceStore sourceStore;
private final SourceAccessGuard sourceAccessGuard;
private final SourceOverviewService overviewService;
@@ -112,8 +109,7 @@ public class SourceController {
public ResponseEntity<Source> save(@RequestBody Source source) {
requireSourceEditingAllowed();
requireNotEditor(source.id(), source.type());
boolean isCreate = source.id() == null || source.id().isBlank();
Source owned = withPreparedOptions(withStoredSecrets(resolveOwnership(source)), isCreate);
Source owned = withStoredSecrets(resolveOwnership(source));
try {
validateConfig(owned);
} catch (IllegalArgumentException e) {
@@ -123,7 +119,7 @@ public class SourceController {
// An edited folder source can change which directory needs watching, so re-sync trigger
// registrations now instead of waiting for the next reconcile.
policyTriggerManager.notifyPoliciesChanged();
return ResponseEntity.ok(revealOnCreate(saved, isCreate));
return ResponseEntity.ok(withMaskedSecrets(saved));
}
@DeleteMapping("/{sourceId}")
@@ -225,36 +221,14 @@ public class SourceController {
/** Validate the config against the bean that handles the source's type, as the engine will. */
private void validateConfig(Source source) {
InputSpec spec = source.toInputSpec();
inputSourceFor(spec)
inputSources.stream()
.filter(inputSource -> inputSource.supports(spec))
.findFirst()
.orElseThrow(
() -> new IllegalArgumentException("unknown source type: " + source.type()))
.validate(spec);
}
/** Let the source type populate server-owned config before persistence. */
private Source withPreparedOptions(Source source, boolean isCreate) {
InputSpec spec = source.toInputSpec();
InputSource input = inputSourceFor(spec).orElse(null);
if (input == null) {
return source;
}
Map<String, Object> prepared = input.prepareOptionsForSave(source.options(), isCreate);
// Treat a null return as "unchanged" (else the record wipes config to empty).
return prepared == null ? source : withOptions(source, prepared);
}
/** Reveal a webhook's minted secret once on the create response; other reads mask. */
private static Source revealOnCreate(Source saved, boolean isCreate) {
if (isCreate && WEBHOOK_TYPE.equals(saved.type())) {
return saved;
}
return withMaskedSecrets(saved);
}
private Optional<InputSource> inputSourceFor(InputSpec spec) {
return inputSources.stream().filter(input -> input.supports(spec)).findFirst();
}
/**
* Editing sources requires the editor role for the caller's team (a team leader on SaaS), the
* same rule as policies. Single-user deployments (login disabled) trust the local operator.
@@ -102,8 +102,7 @@ public class SourceOverviewService {
List.of(),
docs.total(),
docs.last24h(),
docs.last30d(),
null);
docs.last30d());
}
/**
@@ -144,17 +143,7 @@ public class SourceOverviewService {
configRows(source),
docs.total(),
docs.last24h(),
docs.last30d(),
webhookPath(source));
}
/** The server-relative delivery path for a webhook source, else null. Never a secret. */
private static String webhookPath(Source source) {
if (!"webhook".equals(source.type())) {
return null;
}
Object webhookId = source.options().get("webhookId");
return webhookId == null ? null : "/api/v1/webhooks/" + webhookId;
docs.last30d());
}
/** A disabled (paused) source reads as "disabled"; an unreferenced one reads as "unused". */
@@ -2,7 +2,11 @@ package stirling.software.proprietary.policy.source;
import java.util.List;
/** One Sources-overview row; {@code webhookPath} is a webhook's delivery path, else null. */
/**
* One row in the Sources overview: a persisted input connection shown exactly once, with how many
* policies reference it (and which) and how many documents it has fed into runs ({@code docsTotal}
* lifetime plus the trailing 24-hour and 30-day windows).
*/
public record SourceView(
String id,
String name,
@@ -13,8 +17,7 @@ public record SourceView(
List<DetailRow> config,
long docsTotal,
long docs24h,
long docs30d,
String webhookPath) {
long docs30d) {
/** A policy that references this source. */
public record PolicyRef(String id, String name) {}
@@ -1,137 +0,0 @@
package stirling.software.proprietary.policy.trigger;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.engine.SweepKind;
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.policy.webhook.WebhookConfig;
/** Fires webhook policies on delivery plus a periodic reconcile safety net. */
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class WebhookTrigger implements PolicyTrigger {
static final String TYPE = "webhook";
// The webhook source type (equals the trigger type but a distinct concept).
private static final String WEBHOOK_SOURCE_TYPE = "webhook";
private final PolicyStore policyStore;
private final PolicyRunner policyRunner;
private final SourceStore sourceStore;
private final ApplicationProperties applicationProperties;
private volatile ScheduledExecutorService reconciler;
@Override
public String type() {
return TYPE;
}
@Override
public boolean requiresSource() {
return true;
}
@Override
public Set<String> supportedSourceTypes() {
return Set.of(WEBHOOK_SOURCE_TYPE);
}
@Override
public void validate(Policy policy) {
boolean hasWebhookSource =
policy.sourceIds().stream()
.map(sourceStore::get)
.flatMap(java.util.Optional::stream)
.anyMatch(source -> WEBHOOK_SOURCE_TYPE.equals(source.type()));
if (!hasWebhookSource) {
throw new IllegalArgumentException(
"webhook trigger requires at least one webhook input source");
}
}
@Override
public synchronized void start() {
if (reconciler != null) {
return;
}
long reconcileSeconds = applicationProperties.getPolicies().getWatchReconcileSeconds();
reconciler =
Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("policy-webhook-reconcile-", 0).factory());
// First reconcile runs immediately so deliveries spooled before startup are picked up.
reconciler.scheduleAtFixedRate(this::safeReconcile, 0, reconcileSeconds, TimeUnit.SECONDS);
log.info("Webhook trigger started (reconcile every {}s)", reconcileSeconds);
}
@Override
public synchronized void stop() {
if (reconciler != null) {
reconciler.shutdownNow();
reconciler = null;
}
}
/** Run every webhook policy referencing the delivered-to source (LIGHT, best-effort). */
public void fireForWebhook(String webhookId) {
for (Policy policy : policyStore.findByTriggerType(TYPE)) {
if (!referencesWebhook(policy, webhookId)) {
continue;
}
try {
log.debug("Webhook policy {} ({}) saw a delivery", policy.id(), policy.name());
policyRunner.run(policy, SweepKind.LIGHT);
} catch (RuntimeException e) {
log.warn("Webhook run failed for policy {}: {}", policy.id(), e.getMessage());
}
}
}
private void safeReconcile() {
try {
for (Policy policy : policyStore.findByTriggerType(TYPE)) {
try {
policyRunner.run(policy);
} catch (RuntimeException e) {
log.warn(
"Webhook reconcile run failed for policy {}: {}",
policy.id(),
e.getMessage());
}
}
} catch (RuntimeException e) {
log.error("Webhook reconcile failed: {}", e.getMessage(), e);
}
}
/** Whether the policy references a webhook source whose routing id is {@code webhookId}. */
private boolean referencesWebhook(Policy policy, String webhookId) {
for (String sourceId : policy.sourceIds()) {
Source source = sourceStore.get(sourceId).orElse(null);
if (source == null || !WEBHOOK_SOURCE_TYPE.equals(source.type())) {
continue;
}
Object configured = source.options().get(WebhookConfig.WEBHOOK_ID_OPTION);
if (configured != null && configured.toString().equals(webhookId)) {
return true;
}
}
return false;
}
}
@@ -1,89 +0,0 @@
package stirling.software.proprietary.policy.webhook;
import java.util.Map;
/** Webhook source config: server-minted ids, staging via an S3 connection or the local spool. */
public record WebhookConfig(
String webhookId, String signingSecret, Long connectionId, boolean snapshot) {
public static final String WEBHOOK_ID_OPTION = "webhookId";
public static final String SIGNING_SECRET_OPTION = "signingSecret";
public static final String CONNECTION_ID_OPTION = "connectionId";
private static final String MODE_OPTION = "mode";
private static final String MODE_CONSUME = "consume";
private static final String MODE_SNAPSHOT = "snapshot";
/** Reserved key namespace webhook deliveries are staged under in a connection's bucket. */
private static final String STAGING_ROOT = "stirling-webhook";
public static WebhookConfig from(Map<String, Object> options) {
String webhookId = trimmed(options.get(WEBHOOK_ID_OPTION));
if (webhookId == null) {
throw new IllegalArgumentException("webhook config requires a 'webhookId' option");
}
if (!WebhookIds.isValidId(webhookId)) {
throw new IllegalArgumentException("webhook config 'webhookId' has an invalid format");
}
String signingSecret = trimmed(options.get(SIGNING_SECRET_OPTION));
if (signingSecret == null) {
throw new IllegalArgumentException("webhook config requires a 'signingSecret' option");
}
String mode = trimmed(options.get(MODE_OPTION));
if (mode != null && !MODE_CONSUME.equals(mode) && !MODE_SNAPSHOT.equals(mode)) {
throw new IllegalArgumentException(
"webhook config 'mode' must be 'consume' or 'snapshot'");
}
return new WebhookConfig(
webhookId, signingSecret, connectionId(options), MODE_SNAPSHOT.equals(mode));
}
/** Whether deliveries are staged to a durable S3 connection rather than the local spool. */
public boolean usesConnection() {
return connectionId != null;
}
/** Reserved per-webhook staging prefix inside the connection's bucket. */
public String stagingPrefix() {
return STAGING_ROOT + "/" + webhookId;
}
public String mode() {
return snapshot ? MODE_SNAPSHOT : MODE_CONSUME;
}
private 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(
"webhook 'connectionId' is not a valid connection reference: " + reference);
}
}
private static String trimmed(Object value) {
if (value == null) {
return null;
}
String text = value.toString().trim();
return text.isEmpty() ? null : text;
}
/** Never prints the signing secret, so an accidental log line cannot leak it. */
@Override
public String toString() {
return "WebhookConfig[webhookId="
+ webhookId
+ ", connectionId="
+ connectionId
+ ", snapshot="
+ snapshot
+ "]";
}
}
@@ -1,38 +0,0 @@
package stirling.software.proprietary.policy.webhook;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.regex.Pattern;
/** Generates and validates a webhook's routing id and signing secret. */
public final class WebhookIds {
/** URL-safe base64 without padding: the characters a single path segment allows. */
private static final Pattern VALID_ID = Pattern.compile("^[A-Za-z0-9_-]{16,128}$");
private static final SecureRandom RANDOM = new SecureRandom();
private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
private WebhookIds() {}
/** A fresh routing token (~24 chars), unguessable so it cannot be enumerated. */
public static String newWebhookId() {
return randomToken(18);
}
/** A fresh HMAC signing key (~43 chars) revealed to the operator once at creation. */
public static String newSigningSecret() {
return randomToken(32);
}
/** Whether {@code id} is well-formed and safe as a path segment / directory name. */
public static boolean isValidId(String id) {
return id != null && VALID_ID.matcher(id).matches();
}
private static String randomToken(int bytes) {
byte[] buffer = new byte[bytes];
RANDOM.nextBytes(buffer);
return ENCODER.encodeToString(buffer);
}
}
@@ -1,201 +0,0 @@
package stirling.software.proprietary.policy.webhook;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
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.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.trigger.WebhookTrigger;
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
/** Public receiver: HMAC-verifies a signed delivery, stages it, and fires the policies. */
@Slf4j
@RestController
@RequestMapping("/api/v1/webhooks")
@Hidden
@RequiredArgsConstructor
@Tag(name = "Webhooks", description = "Inbound webhook source receiver")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class WebhookReceiverController {
static final String SIGNATURE_HEADER = "X-Stirling-Signature";
static final String FILENAME_HEADER = "X-Stirling-Filename";
private static final String WEBHOOK_TYPE = "webhook";
private final SourceStore sourceStore;
private final WebhookSpool spool;
private final WebhookTrigger webhookTrigger;
private final ApplicationProperties applicationProperties;
private final S3ConnectionResolver connectionResolver;
private final S3ConnectionPool connectionPool;
@PostMapping("/{webhookId}")
@Operation(
summary = "Deliver a document to a webhook source",
description =
"The body is the raw document; sign it with the source's secret and present"
+ " 'sha256=<hex>' in the X-Stirling-Signature header. Returns 202 once"
+ " the document is spooled for the referencing policies.")
public ResponseEntity<WebhookDeliveryResponse> receive(
@PathVariable String webhookId,
@RequestHeader(value = SIGNATURE_HEADER, required = false) String signature,
@RequestHeader(value = FILENAME_HEADER, required = false) String filename,
HttpServletRequest request) {
if (!WebhookIds.isValidId(webhookId)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No such webhook");
}
Source source = findWebhookSource(webhookId);
if (source == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No such webhook");
}
WebhookConfig config = WebhookConfig.from(source.options());
byte[] body = readBoundedBody(request);
if (!WebhookSignatures.verify(config.signingSecret(), body, signature)) {
// Same 401 whether the header was absent or wrong: never confirm a guess.
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid signature");
}
if (!source.enabled()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "Webhook source is paused; deliveries are not accepted");
}
if (body.length == 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Empty request body");
}
String storedName =
config.usesConnection()
? stageToConnection(config, filename, body)
: stageToSpool(webhookId, filename, body);
// Fire the referencing policies now; the trigger's reconcile is the safety net.
webhookTrigger.fireForWebhook(webhookId);
log.info(
"Accepted webhook delivery '{}' ({} bytes) for {}",
storedName,
body.length,
webhookId);
return ResponseEntity.accepted()
.contentType(MediaType.APPLICATION_JSON)
.body(new WebhookDeliveryResponse(true, storedName, body.length));
}
/** The enabled-or-not webhook source whose routing id matches, or null if there is none. */
private Source findWebhookSource(String webhookId) {
for (Source source : sourceStore.all()) {
if (!WEBHOOK_TYPE.equals(source.type())) {
continue;
}
Object configured = source.options().get(WebhookConfig.WEBHOOK_ID_OPTION);
if (configured != null && configured.toString().equals(webhookId)) {
return source;
}
}
return null;
}
/** Stage a delivery to the node-local spool; returns its display (original) name. */
private String stageToSpool(String webhookId, String filename, byte[] body) {
try {
return WebhookSpool.displayName(
spool.store(webhookId, filename, body).getFileName().toString());
} catch (IOException e) {
log.error("Could not spool webhook delivery for {}: {}", webhookId, e.getMessage());
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR, "Could not store delivery");
}
}
/** Stage a delivery to the S3 connection (save-time access check trusted). */
private String stageToConnection(WebhookConfig config, String filename, byte[] body) {
S3Config s3 =
connectionResolver.resolve(
Map.of(
WebhookConfig.CONNECTION_ID_OPTION,
config.connectionId(),
"prefix",
config.stagingPrefix()));
S3Client client = connectionPool.clientFor(s3);
String key = keyPrefix(s3.prefix()) + WebhookSpool.objectKeySuffix(filename);
try {
client.putObject(
PutObjectRequest.builder().bucket(s3.bucket()).key(key).build(),
RequestBody.fromBytes(body));
} catch (SdkException e) {
log.error(
"Could not stage webhook delivery for {} to s3://{}/{}: {}",
config.webhookId(),
s3.bucket(),
key,
e.getMessage());
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR, "Could not store delivery");
}
return WebhookSpool.objectDisplayName(filename);
}
/** The configured prefix as a key-path prefix ("inbox" and "inbox/" mean the same). */
private static String keyPrefix(String prefix) {
if (prefix == null || prefix.isEmpty() || prefix.endsWith("/")) {
return prefix == null ? "" : prefix;
}
return prefix + "/";
}
/** Read the body into memory, capped at {@code policies.webhookMaxBytes}. */
private byte[] readBoundedBody(HttpServletRequest request) {
long maxBytes = applicationProperties.getPolicies().getWebhookMaxBytes();
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] chunk = new byte[8192];
long total = 0;
try (InputStream in = request.getInputStream()) {
int read;
while ((read = in.read(chunk)) != -1) {
total += read;
if (total > maxBytes) {
throw new ResponseStatusException(
HttpStatus.PAYLOAD_TOO_LARGE,
"Delivery exceeds the " + maxBytes + "-byte limit");
}
buffer.write(chunk, 0, read);
}
} catch (IOException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Could not read request body");
}
return buffer.toByteArray();
}
/** The 202 body: the stored (display) name and byte count of an accepted delivery. */
public record WebhookDeliveryResponse(boolean accepted, String filename, int bytes) {}
}
@@ -1,53 +0,0 @@
package stirling.software.proprietary.policy.webhook;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/** HMAC-SHA256 signing of a webhook delivery's raw body (header sha256=<hex>). */
public final class WebhookSignatures {
private static final String ALGORITHM = "HmacSHA256";
private static final String PREFIX = "sha256=";
private WebhookSignatures() {}
/** The header a sender presents for {@code body}: {@code sha256=<lowercase hex>}. */
public static String sign(String signingSecret, byte[] body) {
return PREFIX + HexFormat.of().formatHex(hmac(signingSecret, body));
}
/** Whether the presented signature is valid for {@code body}; false on bad input. */
public static boolean verify(String signingSecret, byte[] body, String presented) {
if (signingSecret == null || presented == null || body == null) {
return false;
}
String hex = presented.trim();
if (hex.regionMatches(true, 0, PREFIX, 0, PREFIX.length())) {
hex = hex.substring(PREFIX.length());
}
byte[] presentedBytes;
try {
presentedBytes = HexFormat.of().parseHex(hex);
} catch (IllegalArgumentException notHex) {
return false;
}
return MessageDigest.isEqual(hmac(signingSecret, body), presentedBytes);
}
private static byte[] hmac(String signingSecret, byte[] body) {
try {
Mac mac = Mac.getInstance(ALGORITHM);
mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), ALGORITHM));
return mac.doFinal(body);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
// HmacSHA256 is a required JCE algorithm and the key is always non-empty here.
throw new IllegalStateException("HMAC-SHA256 unavailable", e);
}
}
}
@@ -1,106 +0,0 @@
package stirling.software.proprietary.policy.webhook;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import stirling.software.common.configuration.InstallationPathConfig;
/** Server-owned per-webhook staging directory under the install path. */
@Component
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class WebhookSpool {
private static final String SPOOL_DIR = "policy-webhook-spool";
private static final String TEMP_SUFFIX = ".part";
private static final String DEFAULT_NAME = "document.pdf";
private static final int UNIQUE_LEN = 32; // UUID hex without dashes
private final Path spoolRoot;
public WebhookSpool() {
this(Path.of(InstallationPathConfig.getPath(), SPOOL_DIR));
}
// Lets a caller (and tests) root the spool at a chosen directory; Spring uses the no-arg one.
public WebhookSpool(Path spoolRoot) {
this.spoolRoot = spoolRoot.toAbsolutePath().normalize();
}
/** The staging directory for one webhook. Never escapes the spool root; may not yet exist. */
public Path dirFor(String webhookId) {
if (!WebhookIds.isValidId(webhookId)) {
throw new IllegalArgumentException("invalid webhookId");
}
Path dir = spoolRoot.resolve(webhookId).normalize();
if (!dir.getParent().equals(spoolRoot)) {
// A validated id is a single safe segment; this only trips on a bug, never user input.
throw new IllegalArgumentException("invalid webhookId");
}
return dir;
}
/** Write a delivery into the spool atomically (staged .part, then moved into place). */
public Path store(String webhookId, String filename, byte[] content) throws IOException {
Path dir = dirFor(webhookId);
Files.createDirectories(dir);
String finalName = spoolName(filename);
Path target = dir.resolve(finalName);
Path temp = dir.resolve("." + finalName + TEMP_SUFFIX);
Files.write(temp, content);
try {
Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException atomicUnsupported) {
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
}
return target;
}
/** The spool file name for a delivery: a unique prefix plus the sanitised original name. */
static String spoolName(String filename) {
return UUID.randomUUID().toString().replace("-", "") + "-" + sanitize(filename);
}
/** The original name recovered from a spool file name, for the resolved input's filename. */
public static String displayName(String spoolFileName) {
int dash = spoolFileName.indexOf('-');
// The unique prefix is fixed-length hex with no dashes, so the first dash is the separator.
if (dash == UNIQUE_LEN && dash + 1 < spoolFileName.length()) {
return spoolFileName.substring(dash + 1);
}
return spoolFileName;
}
/** S3 object-key suffix {@code <unique>/<name>}; the subfolder keeps the basename clean. */
public static String objectKeySuffix(String filename) {
return UUID.randomUUID().toString().replace("-", "") + "/" + sanitize(filename);
}
/** The staged delivery's display name (basename of {@link #objectKeySuffix}). */
public static String objectDisplayName(String filename) {
return sanitize(filename);
}
/** Reduce a client-supplied filename to a safe bare basename; fall back to a default. */
private static String sanitize(String filename) {
if (filename == null) {
return DEFAULT_NAME;
}
String base = filename.replace('\\', '/');
int slash = base.lastIndexOf('/');
if (slash >= 0) {
base = base.substring(slash + 1);
}
base = base.replaceAll("[^A-Za-z0-9._-]", "_").trim();
// Strip leading dots so the result is never hidden (which resolve would skip) or empty.
while (base.startsWith(".")) {
base = base.substring(1);
}
return base.isEmpty() ? DEFAULT_NAME : base;
}
}
@@ -20,8 +20,9 @@ public final class SecretMasker {
private static final Pattern SENSITIVE =
RegexPatternUtils.getInstance()
.getPattern(
// specific keys precede plain "secret" so camelCase still matches.
"(?i)\\b(password|token|secret[_-]?access[_-]?key|signing[_-]?secret|secret|api[_-]?key|authorization|auth|jwt|cred|cert)\\b");
// secret[_-]?access[_-]?key precedes plain secret so camelCase keys
// like secretAccessKey (no word boundary after "secret") still match.
"(?i)\\b(password|token|secret[_-]?access[_-]?key|secret|api[_-]?key|authorization|auth|jwt|cred|cert)\\b");
private SecretMasker() {}
@@ -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,58 +52,7 @@ class IntegrationConfigServiceTest {
@Mock
private stirling.software.proprietary.access.repository.ResourceGrantRepository grantRepository;
@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));
}
@InjectMocks private IntegrationConfigService service;
@Test
void createDelegatesOwnershipAndSanitizesConfig() {
@@ -4,7 +4,6 @@ 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;
@@ -195,29 +194,6 @@ 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
@@ -82,28 +82,6 @@ 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");
@@ -23,7 +23,6 @@ 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;
@@ -83,9 +82,7 @@ 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), S3TestConnections.legacyResolver());
source = new S3InputSource(new S3ConnectionPool(properties));
ledger = new InProcessProcessedLedger();
ctx = new RecordingContext();
}
@@ -164,9 +161,7 @@ class S3InputSourceMinioTest {
@Test
void aPrivateEndpointIsRejectedWithoutTheOperatorOptIn() {
S3InputSource guarded =
new S3InputSource(
new S3ConnectionPool(new ApplicationProperties()),
S3TestConnections.legacyResolver());
new S3InputSource(new S3ConnectionPool(new ApplicationProperties()));
assertThatThrownBy(() -> guarded.validate(spec(Map.of())))
.isInstanceOf(IllegalArgumentException.class)
@@ -29,7 +29,6 @@ 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;
@@ -65,8 +64,7 @@ class S3InputSourceTest {
void setUp() {
source =
new S3InputSource(
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client),
S3TestConnections.legacyResolver());
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client));
ledger = new InProcessProcessedLedger();
ctx = new RecordingContext();
}
@@ -1,221 +0,0 @@
package stirling.software.proprietary.policy.input;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
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.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.util.FileReadinessChecker;
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.webhook.WebhookConfig;
import stirling.software.proprietary.policy.webhook.WebhookSpool;
/** Tests for {@link WebhookInputSource}: ledger-backed read/consume, and id/secret minting. */
@ExtendWith(MockitoExtension.class)
class WebhookInputSourceTest {
private static final String POLICY = "p1";
private static final String WEBHOOK_ID = "testwebhookid1234";
@Mock private FileReadinessChecker readinessChecker;
@Mock private S3InputSource s3InputSource;
@TempDir Path tempDir;
private WebhookSpool spool;
private WebhookInputSource source;
private InProcessProcessedLedger ledger;
private RecordingContext ctx;
@BeforeEach
void setUp() {
spool = new WebhookSpool(tempDir.resolve("spool"));
source = new WebhookInputSource(spool, readinessChecker, s3InputSource);
ledger = new InProcessProcessedLedger();
ctx = new RecordingContext();
lenient().when(readinessChecker.isReady(any())).thenReturn(true);
}
private static InputSpec spec(String mode) {
return new InputSpec(
"webhook",
Map.of("webhookId", WEBHOOK_ID, "signingSecret", "secret", "mode", mode));
}
@Test
void consumeRemovesTheDeliveryOnceProcessed() throws IOException {
Path delivered = spool.store(WEBHOOK_ID, "doc.pdf", "data".getBytes());
List<ResolvedInput> work = source.resolve(spec("consume"), ctx);
assertEquals(1, work.size());
assertEquals("doc.pdf", work.get(0).inputs().primary().get(0).getFilename());
// In flight: still spooled, but a second sweep does not pick it up again.
assertTrue(Files.exists(delivered));
assertTrue(source.resolve(spec("consume"), ctx).isEmpty());
work.get(0).onComplete().accept(true);
assertTrue(Files.notExists(delivered));
assertTrue(source.resolve(spec("consume"), ctx).isEmpty());
}
@Test
void aFailedRunLeavesTheDeliveryInPlace() throws IOException {
Path delivered = spool.store(WEBHOOK_ID, "doc.pdf", "data".getBytes());
List<ResolvedInput> work = source.resolve(spec("consume"), ctx);
work.get(0).onComplete().accept(false);
assertTrue(Files.exists(delivered));
}
@Test
void snapshotReReadsEveryRunAndNeverDeletes() throws IOException {
Path delivered = spool.store(WEBHOOK_ID, "doc.pdf", "data".getBytes());
assertEquals(1, source.resolve(spec("snapshot"), ctx).size());
List<ResolvedInput> second = source.resolve(spec("snapshot"), ctx);
assertEquals(1, second.size());
second.get(0).onComplete().accept(true);
assertTrue(Files.exists(delivered));
}
@Test
void nothingDeliveredIsAnEmptySourceNotAnError() throws IOException {
List<ResolvedInput> work = source.resolve(spec("consume"), ctx);
assertTrue(work.isEmpty());
assertTrue(ctx.present.isEmpty());
}
@Test
void validateRejectsMissingIdOrSecret() {
assertThrows(
IllegalArgumentException.class,
() -> source.validate(new InputSpec("webhook", Map.of("signingSecret", "s"))));
assertThrows(
IllegalArgumentException.class,
() -> source.validate(new InputSpec("webhook", Map.of("webhookId", WEBHOOK_ID))));
}
@Test
void prepareMintsIdAndSecretOnCreate() {
Map<String, Object> prepared =
source.prepareOptionsForSave(Map.of("mode", "consume"), true);
String id = prepared.get(WebhookConfig.WEBHOOK_ID_OPTION).toString();
String secret = prepared.get(WebhookConfig.SIGNING_SECRET_OPTION).toString();
assertFalse(id.isBlank());
assertFalse(secret.isBlank());
assertEquals("consume", prepared.get("mode"));
Map<String, Object> other = source.prepareOptionsForSave(Map.of(), true);
assertNotEquals(id, other.get(WebhookConfig.WEBHOOK_ID_OPTION).toString());
}
@Test
void prepareLeavesAnExistingWebhookUntouchedOnEdit() {
Map<String, Object> existing =
Map.of("webhookId", WEBHOOK_ID, "signingSecret", "keepme", "mode", "snapshot");
Map<String, Object> prepared = source.prepareOptionsForSave(existing, false);
assertEquals(WEBHOOK_ID, prepared.get("webhookId"));
assertEquals("keepme", prepared.get("signingSecret"));
}
@Test
void aConnectionBackedWebhookDelegatesToTheS3SourceUnderItsReservedPrefix() throws IOException {
when(s3InputSource.resolve(any(), any())).thenReturn(List.of());
InputSpec spec =
new InputSpec(
"webhook",
Map.of(
"webhookId",
WEBHOOK_ID,
"signingSecret",
"secret",
"mode",
"consume",
"connectionId",
7));
source.resolve(spec, ctx);
ArgumentCaptor<InputSpec> delegated = ArgumentCaptor.forClass(InputSpec.class);
verify(s3InputSource).resolve(delegated.capture(), eq(ctx));
InputSpec s3 = delegated.getValue();
assertEquals("s3", s3.type());
assertEquals(7L, ((Number) s3.options().get("connectionId")).longValue());
assertEquals("stirling-webhook/" + WEBHOOK_ID, s3.options().get("prefix"));
assertEquals("consume", s3.options().get("mode"));
}
@Test
void aConnectionBackedWebhookValidatesTheConnectionThroughTheS3Source() {
InputSpec spec =
new InputSpec(
"webhook",
Map.of(
"webhookId",
WEBHOOK_ID,
"signingSecret",
"secret",
"connectionId",
7));
source.validate(spec);
// Save-time validation defers to the S3 source, which ownership-checks the connection.
verify(s3InputSource).validate(any());
}
/** Policy-scoped context backed by the in-process ledger, recording presence reports. */
private class RecordingContext implements ResolveContext {
private final List<String> present = new ArrayList<>();
@Override
public boolean claim(String identity, String gate, Supplier<String> contentHash) {
return ledger.claim(POLICY, identity, gate, contentHash);
}
@Override
public void settle(
String identity, String finalGate, String finalContentHash, boolean success) {
ledger.settle(POLICY, identity, finalGate, finalContentHash, success);
}
@Override
public boolean allSettledDone(String identity) {
return ledger.allSettledDone(identity);
}
@Override
public void reportPresent(Collection<String> identities) {
present.addAll(identities);
}
}
}
@@ -28,7 +28,6 @@ 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;
@@ -91,8 +90,8 @@ class S3OutputSinkMinioTest {
properties.getPolicies().setAllowPrivateS3Endpoints(true);
S3ConnectionPool pool = new S3ConnectionPool(properties);
ledger = new InProcessProcessedLedger();
sink = new S3OutputSink(pool, S3TestConnections.legacyResolver(), ledger);
source = new S3InputSource(pool, S3TestConnections.legacyResolver());
sink = new S3OutputSink(pool, ledger);
source = new S3InputSource(pool);
}
@Test
@@ -32,7 +32,6 @@ 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;
@@ -67,7 +66,6 @@ class S3OutputSinkTest {
sink =
new S3OutputSink(
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client),
S3TestConnections.legacyResolver(),
ledger);
}
@@ -1,212 +0,0 @@
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);
}
}
@@ -1,52 +0,0 @@
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();
}
}
@@ -1,149 +0,0 @@
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;
}
}
@@ -1,71 +0,0 @@
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);
}
}
@@ -1,24 +0,0 @@
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));
}
}
@@ -1,42 +1,33 @@
package stirling.software.proprietary.policy.source;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
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.anyBoolean;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.http.ResponseEntity;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.FileReadinessChecker;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.input.S3InputSource;
import stirling.software.proprietary.policy.input.WebhookInputSource;
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.store.InProcessPolicyStore;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
import stirling.software.proprietary.policy.webhook.WebhookSpool;
import stirling.software.proprietary.util.SecretMasker;
/**
@@ -50,9 +41,6 @@ class SourceControllerTest {
private final PolicyStore policyStore = new InProcessPolicyStore();
private PolicyTriggerManager triggerManager;
private SourceController controller;
private SourceController webhookController;
@TempDir Path tempDir;
@BeforeEach
void setUp() {
@@ -70,11 +58,9 @@ class SourceControllerTest {
policyGuard,
new InProcessSourceDocCounter());
triggerManager = mock(PolicyTriggerManager.class);
// A permissive input source; stub prepareOptionsForSave as a pass-through.
// A permissive input source so config validation passes and save can be exercised.
InputSource folderInput = mock(InputSource.class);
when(folderInput.supports(any())).thenReturn(true);
when(folderInput.prepareOptionsForSave(any(), anyBoolean()))
.thenAnswer(invocation -> invocation.getArgument(0));
controller =
new SourceController(
sourceStore,
@@ -86,48 +72,6 @@ class SourceControllerTest {
triggerManager,
properties,
List.of(folderInput));
WebhookInputSource webhookInput =
new WebhookInputSource(
new WebhookSpool(tempDir),
mock(FileReadinessChecker.class),
mock(S3InputSource.class));
webhookController =
new SourceController(
sourceStore,
sourceGuard,
overviewService,
policyStore,
policyGuard,
authority,
triggerManager,
properties,
List.of(webhookInput));
}
@Test
void creatingAWebhookRevealsItsSecretOnceThenMasks() {
Source created =
webhookController
.save(
new Source(
null,
"Partner uploads",
"webhook",
Map.of("mode", "consume"),
true,
null,
null))
.getBody();
String secret = String.valueOf(created.options().get("signingSecret"));
String webhookId = String.valueOf(created.options().get("webhookId"));
assertNotEquals(SecretMasker.REDACTED, secret);
assertFalse(secret.isBlank());
assertFalse(webhookId.isBlank());
Source read = webhookController.get(created.id()).getBody();
assertEquals(SecretMasker.REDACTED, read.options().get("signingSecret"));
assertEquals(webhookId, read.options().get("webhookId"));
}
@Test
@@ -1,117 +0,0 @@
package stirling.software.proprietary.policy.trigger;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
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.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.engine.SweepKind;
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.model.TriggerConfig;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/** Tests for {@link WebhookTrigger}: fires only policies referencing the delivered-to webhook. */
@ExtendWith(MockitoExtension.class)
class WebhookTriggerTest {
private static final String TYPE = "webhook";
@Mock private PolicyStore policyStore;
@Mock private PolicyRunner policyRunner;
private final SourceStore sourceStore = new InProcessSourceStore();
private WebhookTrigger trigger;
@BeforeEach
void setUp() {
trigger =
new WebhookTrigger(
policyStore, policyRunner, sourceStore, new ApplicationProperties());
}
@Test
void firesOnlyPoliciesReferencingTheDeliveredWebhook() {
Policy matching = webhookPolicy("a", "whkA");
Policy other = webhookPolicy("b", "whkB");
when(policyStore.findByTriggerType(TYPE)).thenReturn(List.of(matching, other));
trigger.fireForWebhook("whkA");
verify(policyRunner).run(matching, SweepKind.LIGHT);
verify(policyRunner, never()).run(other, SweepKind.LIGHT);
}
@Test
void ignoresADeliveryForAnUnknownWebhookId() {
Policy policy = webhookPolicy("a", "whkA");
when(policyStore.findByTriggerType(TYPE)).thenReturn(List.of(policy));
trigger.fireForWebhook("whkZ");
verify(policyRunner, never()).run(any(), any(SweepKind.class));
}
@Test
void validateRequiresAWebhookSource() {
assertThrows(
IllegalArgumentException.class,
() -> trigger.validate(policy("p", webhookTriggerConfig(), List.of())));
trigger.validate(webhookPolicy("p", "whkA"));
}
private static TriggerConfig webhookTriggerConfig() {
return new TriggerConfig(TYPE, Map.of());
}
/** Persist a webhook source with the given routing id and return a policy referencing it. */
private Policy webhookPolicy(String id, String webhookId) {
String sourceId =
sourceStore
.save(
new Source(
null,
"hook",
"webhook",
Map.of(
"webhookId",
webhookId,
"signingSecret",
"s",
"mode",
"consume"),
true,
"owner",
null))
.id();
return policy(id, webhookTriggerConfig(), List.of(sourceId));
}
private static Policy policy(String id, TriggerConfig trigger, List<String> sourceIds) {
return new Policy(
id,
"hook",
"owner",
true,
trigger,
sourceIds,
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline());
}
}
@@ -1,186 +0,0 @@
package stirling.software.proprietary.policy.webhook;
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.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
import stirling.software.proprietary.policy.s3.S3ConnectionResolver;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.trigger.WebhookTrigger;
import stirling.software.proprietary.policy.webhook.WebhookReceiverController.WebhookDeliveryResponse;
/** Tests for the public webhook receiver: valid delivery spools + fires; bad requests rejected. */
class WebhookReceiverControllerTest {
private static final String WEBHOOK_ID = "receivertestid12";
private static final String SECRET = "topsecret";
private static final byte[] BODY = "a pdf".getBytes(StandardCharsets.UTF_8);
@TempDir Path tempDir;
private SourceStore sourceStore;
private WebhookSpool spool;
private WebhookTrigger trigger;
private ApplicationProperties properties;
private WebhookReceiverController controller;
@BeforeEach
void setUp() {
sourceStore = new InProcessSourceStore();
sourceStore.save(webhookSource(true));
spool = new WebhookSpool(tempDir.resolve("spool"));
trigger = mock(WebhookTrigger.class);
properties = new ApplicationProperties();
// The local-disk tests never touch a connection; mocks stand in for the S3 collaborators.
controller =
new WebhookReceiverController(
sourceStore,
spool,
trigger,
properties,
mock(S3ConnectionResolver.class),
mock(S3ConnectionPool.class));
}
private static Source webhookSource(boolean enabled) {
return new Source(
"s1",
"Partner uploads",
"webhook",
Map.of("webhookId", WEBHOOK_ID, "signingSecret", SECRET, "mode", "consume"),
enabled,
"owner",
null);
}
private static MockHttpServletRequest request(byte[] body) {
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/api/v1/webhooks/" + WEBHOOK_ID);
req.setContent(body);
return req;
}
@Test
void aValidDeliveryIsSpooledAndFiresTheTrigger() throws IOException {
String signature = WebhookSignatures.sign(SECRET, BODY);
ResponseEntity<WebhookDeliveryResponse> response =
controller.receive(WEBHOOK_ID, signature, "invoice.pdf", request(BODY));
assertEquals(202, response.getStatusCode().value());
assertTrue(response.getBody().accepted());
assertEquals("invoice.pdf", response.getBody().filename());
assertEquals(1, spooledFiles().size());
verify(trigger).fireForWebhook(WEBHOOK_ID);
}
@Test
void aWrongSignatureIsRejectedAndStoresNothing() {
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() ->
controller.receive(
WEBHOOK_ID, "sha256=deadbeef", "x.pdf", request(BODY)));
assertEquals(401, ex.getStatusCode().value());
assertTrue(spooledFiles().isEmpty());
verify(trigger, never()).fireForWebhook(WEBHOOK_ID);
}
@Test
void anUnknownWebhookIsNotFound() {
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() ->
controller.receive(
"unknownwebhookid",
WebhookSignatures.sign(SECRET, BODY),
"x.pdf",
request(BODY)));
assertEquals(404, ex.getStatusCode().value());
}
@Test
void aPausedSourceRejectsDeliveries() {
sourceStore.save(webhookSource(false));
String signature = WebhookSignatures.sign(SECRET, BODY);
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> controller.receive(WEBHOOK_ID, signature, "x.pdf", request(BODY)));
assertEquals(403, ex.getStatusCode().value());
assertTrue(spooledFiles().isEmpty());
}
@Test
void anEmptyBodyIsRejected() {
byte[] empty = new byte[0];
String signature = WebhookSignatures.sign(SECRET, empty);
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> controller.receive(WEBHOOK_ID, signature, null, request(empty)));
assertEquals(400, ex.getStatusCode().value());
}
@Test
void anOversizeDeliveryIsRejectedBeforeStoring() {
properties.getPolicies().setWebhookMaxBytes(2);
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() ->
controller.receive(
WEBHOOK_ID,
WebhookSignatures.sign(SECRET, BODY),
"x.pdf",
request(BODY)));
assertEquals(413, ex.getStatusCode().value());
assertTrue(spooledFiles().isEmpty());
}
private List<Path> spooledFiles() {
Path dir = spool.dirFor(WEBHOOK_ID);
if (!Files.isDirectory(dir)) {
return List.of();
}
try (Stream<Path> entries = Files.list(dir)) {
return entries.filter(Files::isRegularFile)
.filter(p -> !p.getFileName().toString().startsWith("."))
.toList();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
@@ -1,263 +0,0 @@
package stirling.software.proprietary.policy.webhook;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockHttpServletRequest;
import org.testcontainers.containers.MinIOContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.FileReadinessChecker;
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.policy.input.ResolveContext;
import stirling.software.proprietary.policy.input.ResolvedInput;
import stirling.software.proprietary.policy.input.S3InputSource;
import stirling.software.proprietary.policy.input.WebhookInputSource;
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.S3ConnectionResolver;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.trigger.WebhookTrigger;
import stirling.software.proprietary.security.service.UserService;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
import software.amazon.awssdk.services.s3.model.S3Object;
import tools.jackson.databind.ObjectMapper;
/** End-to-end connection-backed webhook against MinIO: stage, read via S3 delegation, consume. */
@Testcontainers(disabledWithoutDocker = true)
class WebhookS3ConnectionMinioTest {
private static final String POLICY = "p1";
private static final String ACCESS_KEY = "minioadmin";
private static final String SECRET_KEY = "minioadmin";
private static final String WEBHOOK_ID = "miniowebhookid12";
private static final String SECRET = "topsecret";
private static final long CONNECTION_ID = 7L;
@Container
static MinIOContainer minio =
new MinIOContainer("minio/minio:latest")
.withUserName(ACCESS_KEY)
.withPassword(SECRET_KEY);
private static S3Client adminClient;
private static int bucketCounter;
@TempDir Path tempDir;
private String bucket;
private String stagingPrefix;
private WebhookReceiverController receiver;
private WebhookInputSource inputSource;
private InProcessProcessedLedger ledger;
private RecordingContext ctx;
@BeforeEach
void setUp() {
if (adminClient == null) {
adminClient =
S3Client.builder()
.endpointOverride(URI.create(minio.getS3URL()))
.httpClient(UrlConnectionHttpClient.create())
.region(Region.US_EAST_1)
.credentialsProvider(
StaticCredentialsProvider.create(
AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY)))
.serviceConfiguration(
S3Configuration.builder().pathStyleAccessEnabled(true).build())
.build();
}
bucket = "webhook-inbox-" + ++bucketCounter;
adminClient.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
stagingPrefix = "stirling-webhook/" + WEBHOOK_ID + "/";
// The MinIO endpoint resolves to loopback, so the operator opt-in must be on.
ApplicationProperties properties = new ApplicationProperties();
properties.getPolicies().setAllowPrivateS3Endpoints(true);
// A resolver over a MinIO S3 connection; no principal here, so ownership is skipped.
S3ConnectionResolver resolver = resolverFor(minioConnection());
S3ConnectionPool pool = new S3ConnectionPool(properties);
S3InputSource s3 = new S3InputSource(pool, resolver);
inputSource =
new WebhookInputSource(
new WebhookSpool(tempDir.resolve("spool")),
mock(FileReadinessChecker.class),
s3);
InProcessSourceStore sourceStore = new InProcessSourceStore();
sourceStore.save(
new Source(
"s1",
"Partner uploads",
"webhook",
Map.of(
"webhookId", WEBHOOK_ID,
"signingSecret", SECRET,
"mode", "consume",
"connectionId", CONNECTION_ID),
true,
"owner",
null));
receiver =
new WebhookReceiverController(
sourceStore,
new WebhookSpool(tempDir.resolve("spool")),
mock(WebhookTrigger.class),
properties,
resolver,
pool);
ledger = new InProcessProcessedLedger();
ctx = new RecordingContext();
}
@Test
void aDeliveryIsStagedToTheConnectionThenReadAndConsumed() throws IOException {
byte[] body = "a pdf".getBytes(StandardCharsets.UTF_8);
String signature = WebhookSignatures.sign(SECRET, body);
var response = receiver.receive(WEBHOOK_ID, signature, "invoice.pdf", request(body));
assertThat(response.getStatusCode().value()).isEqualTo(202);
List<S3Object> staged = listUnder(stagingPrefix);
assertThat(staged).hasSize(1);
// Staged under a unique subfolder so the object basename stays the clean original name.
assertThat(staged.get(0).key()).endsWith("/invoice.pdf");
List<ResolvedInput> work = inputSource.resolve(webhookSpec(), ctx);
assertThat(work).hasSize(1);
assertThat(work.get(0).inputs().primary().get(0).getFilename()).isEqualTo("invoice.pdf");
assertThat(read(work.get(0))).isEqualTo("a pdf");
// In flight: a second sweep claims nothing.
assertThat(inputSource.resolve(webhookSpec(), ctx)).isEmpty();
work.get(0).onComplete().accept(true);
assertThat(listUnder(stagingPrefix)).isEmpty();
assertThat(inputSource.resolve(webhookSpec(), ctx)).isEmpty();
}
@Test
void aRejectedDeliveryStagesNothing() {
byte[] body = "a pdf".getBytes(StandardCharsets.UTF_8);
try {
receiver.receive(WEBHOOK_ID, "sha256=deadbeef", "x.pdf", request(body));
} catch (RuntimeException expected) {
// 401 invalid signature
}
assertThat(listUnder(stagingPrefix)).isEmpty();
}
private IntegrationConfig minioConnection() {
IntegrationConfig connection = new IntegrationConfig();
connection.setId(CONNECTION_ID);
connection.setIntegrationType(IntegrationType.S3);
connection.setName("minio");
connection.setEnabled(true);
connection.setConfig(
new ObjectMapper()
.writeValueAsString(
Map.of(
"bucket", bucket,
"region", "us-east-1",
"endpoint", minio.getS3URL(),
"accessKeyId", ACCESS_KEY,
"secretAccessKey", SECRET_KEY)));
return connection;
}
private static S3ConnectionResolver resolverFor(IntegrationConfig connection) {
IntegrationConfigRepository connections = mock(IntegrationConfigRepository.class);
when(connections.findById(connection.getId())).thenReturn(Optional.of(connection));
return new S3ConnectionResolver(
connections, mock(OwnershipService.class), mock(UserService.class));
}
private static InputSpec webhookSpec() {
return new InputSpec(
"webhook",
Map.of(
"webhookId", WEBHOOK_ID,
"signingSecret", SECRET,
"mode", "consume",
"connectionId", CONNECTION_ID));
}
private static MockHttpServletRequest request(byte[] body) {
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/api/v1/webhooks/" + WEBHOOK_ID);
req.setContent(body);
return req;
}
private List<S3Object> listUnder(String prefix) {
return adminClient
.listObjectsV2(ListObjectsV2Request.builder().bucket(bucket).prefix(prefix).build())
.contents();
}
private static String read(ResolvedInput unit) throws IOException {
try (InputStream stream = unit.inputs().primary().get(0).getInputStream()) {
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
}
private class RecordingContext implements ResolveContext {
private final List<String> present = new ArrayList<>();
@Override
public boolean claim(String identity, String gate, Supplier<String> contentHash) {
return ledger.claim(POLICY, identity, gate, contentHash);
}
@Override
public void settle(
String identity, String finalGate, String finalContentHash, boolean success) {
ledger.settle(POLICY, identity, finalGate, finalContentHash, success);
}
@Override
public boolean allSettledDone(String identity) {
return ledger.allSettledDone(identity);
}
@Override
public void reportPresent(Collection<String> identities) {
present.addAll(identities);
}
}
}
@@ -1,49 +0,0 @@
package stirling.software.proprietary.policy.webhook;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
/** The HMAC-SHA256 webhook signing scheme: correct signatures verify, tampered ones do not. */
class WebhookSignaturesTest {
private static final String SECRET = "whsec_test_secret";
private static final byte[] BODY = "the document bytes".getBytes(StandardCharsets.UTF_8);
@Test
void aFreshlySignedBodyVerifies() {
String header = WebhookSignatures.sign(SECRET, BODY);
assertTrue(header.startsWith("sha256="));
assertTrue(WebhookSignatures.verify(SECRET, BODY, header));
}
@Test
void abarehexSignatureVerifiesToo() {
String header = WebhookSignatures.sign(SECRET, BODY);
String bareHex = header.substring("sha256=".length());
assertTrue(WebhookSignatures.verify(SECRET, BODY, bareHex));
}
@Test
void aWrongSecretDoesNotVerify() {
String header = WebhookSignatures.sign(SECRET, BODY);
assertFalse(WebhookSignatures.verify("other-secret", BODY, header));
}
@Test
void atamperedBodyDoesNotVerify() {
String header = WebhookSignatures.sign(SECRET, BODY);
byte[] tampered = "the document byteS".getBytes(StandardCharsets.UTF_8);
assertFalse(WebhookSignatures.verify(SECRET, tampered, header));
}
@Test
void aMissingOrMalformedHeaderIsFalseNotAnError() {
assertFalse(WebhookSignatures.verify(SECRET, BODY, null));
assertFalse(WebhookSignatures.verify(SECRET, BODY, "sha256=not-hex"));
assertFalse(WebhookSignatures.verify(SECRET, BODY, ""));
}
}
@@ -103,18 +103,6 @@ class SecretMaskerTest {
assertEquals("AKIAEXAMPLE", result.get("accessKeyId"));
}
@Test
@DisplayName("should mask camelCase signingSecret despite no word boundary")
void shouldMaskCamelCaseSigningSecret() {
Map<String, Object> input = Map.of("signingSecret", "shh", "webhookId", "whk_abc");
Map<String, Object> result = SecretMasker.mask(input);
assertEquals(SecretMasker.REDACTED, result.get("signingSecret"));
// The routing id is a public URL token, not a secret.
assertEquals("whk_abc", result.get("webhookId"));
}
@Test
@DisplayName("should mask nested map sensitive keys")
void shouldMaskNestedMapSensitiveKeys() {
+36
View File
@@ -43,6 +43,17 @@ ext {
jwtVersion = "0.13.0"
awsSdkVersion = "2.44.12"
testcontainersMinioVersion = "1.21.4"
// Security CVE overrides for Spring Boot 4 BOM-managed transitives
nettyVersion = "4.2.15.Final"
jackson2Version = "2.22.0"
// jackson-annotations ships 2-component versions (2.22, not 2.22.0)
jackson2AnnotationsVersion = "2.22"
// Jackson 3 (tools.jackson) used by Spring Boot 4; 3.1.4 patches CVE-2026-54512..54518
jackson3Version = "3.1.4"
postgresqlVersion = "42.7.11"
// velocity-engine-core 2.3 (via shib-velocity SAML stack) shades a vulnerable
// commons-io 2.8.0 (CVE-2024-47554) into its jar; 2.4.1 no longer shades it
velocityVersion = "2.4.1"
// junit-platform-launcher version managed by Spring Boot BOM
modernJavaVersion = 25
}
@@ -222,10 +233,33 @@ subprojects {
resolutionStrategy.force "org.apache.commons:commons-lang3:${commonsLang3}"
// CVE-2024-47554: commons-io DoS prevention
resolutionStrategy.force "commons-io:commons-io:${commonsIoVersion}"
// CVE-2024-47554: velocity-engine-core 2.3 shades commons-io 2.8.0; 2.4.1 unshades it
resolutionStrategy.force "org.apache.velocity:velocity-engine-core:${velocityVersion}"
// Keep BouncyCastle modules aligned to avoid runtime linkage errors
resolutionStrategy.force "org.bouncycastle:bcprov-jdk18on:${bouncycastleVersion}"
resolutionStrategy.force "org.bouncycastle:bcpkix-jdk18on:${bouncycastleVersion}"
resolutionStrategy.force "org.bouncycastle:bcutil-jdk18on:${bouncycastleVersion}"
resolutionStrategy.eachDependency { details ->
// Netty 4.2.x: CVE-2026-44249/45416/45673/45674/47244/47691/48043/50010/50020/50560
// /42579/42580/42581/42583/42584/42585/42587/41417 - force 4.2.15.Final across all modules
if (details.requested.group == "io.netty") {
details.useVersion nettyVersion
details.because "CVE-2026 netty fixes (>=4.2.15.Final)"
}
// Jackson 2.x: CVE-2026-54512/54513/54514/54515/54516/54517/54518 - force 2.22.0.
// jackson-annotations uses 2-component versions (2.22), unlike core/databind (2.22.0).
if (details.requested.group.startsWith("com.fasterxml.jackson")) {
details.useVersion(details.requested.name == "jackson-annotations"
? jackson2AnnotationsVersion : jackson2Version)
details.because "CVE-2026-54512..54518 jackson 2.x fixes (>=2.22.0)"
}
// Jackson 3 (tools.jackson) ships with Spring Boot 4; same CVE-2026-54512..54518
// family is fixed in 3.1.4 (stays in Spring Boot's 3.1.x line).
if (details.requested.group.startsWith("tools.jackson")) {
details.useVersion jackson3Version
details.because "CVE-2026-54512..54518 jackson 3.x fixes (>=3.1.4)"
}
}
}
dependencyManagement {
@@ -235,6 +269,8 @@ subprojects {
dependencies {
// Override BOM-managed commons-lang3 for CVE-2025-48924 fix
dependency "org.apache.commons:commons-lang3:$commonsLang3"
// CVE-2026-42198: Spring Boot 4 BOM pins postgresql 42.7.10; override to patched 42.7.11
dependency "org.postgresql:postgresql:$postgresqlVersion"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# Stirling-PDF backend-only image — JAR built with -PbuildWithFrontend=false, UI ships separately.
ARG BASE_VERSION=1.0.2
ARG BASE_VERSION=1.0.4
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
# Stage 1: Build the Java application (backend only, no frontend)
+10 -6
View File
@@ -5,7 +5,7 @@
ARG TARGETPLATFORM
# Stage 1: Build and strip Calibre
FROM ubuntu:noble@sha256:c4a8d5503dfb2a3eb8ab5f807da5bc69a85730fb49b5cfca2330194ebcc41c7b AS calibre-build
FROM ubuntu:noble@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54 AS calibre-build
ARG TARGETPLATFORM
ARG CALIBRE_VERSION=9.4.0
ARG CALIBRE_STRIP_WEBENGINE=false
@@ -270,7 +270,7 @@ RUN if [ "${CALIBRE_STRIP_WEBENGINE}" = "true" ]; then \
# Stage 2: Build Ghostscript from source
FROM ubuntu:noble@sha256:c4a8d5503dfb2a3eb8ab5f807da5bc69a85730fb49b5cfca2330194ebcc41c7b AS gs-build
FROM ubuntu:noble@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54 AS gs-build
ARG TARGETPLATFORM
ARG GS_VERSION=10.06.0
@@ -294,7 +294,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
# Stage 3: Build PDF Tools (QPDF and ImageMagick 7)
FROM ubuntu:noble@sha256:c4a8d5503dfb2a3eb8ab5f807da5bc69a85730fb49b5cfca2330194ebcc41c7b AS pdf-tools-build
FROM ubuntu:noble@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54 AS pdf-tools-build
ARG TARGETPLATFORM
ARG QPDF_VERSION=12.3.2
ARG IM_VERSION=7.1.2-13
@@ -339,7 +339,7 @@ RUN mkdir -p /magick-export/usr/bin \
# Stage 4: Build Python venv
FROM ubuntu:noble@sha256:c4a8d5503dfb2a3eb8ab5f807da5bc69a85730fb49b5cfca2330194ebcc41c7b AS python-venv-build
FROM ubuntu:noble@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54 AS python-venv-build
ARG TARGETPLATFORM
ARG UNOSERVER_VERSION=3.6
@@ -352,7 +352,8 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
python3 -m venv /opt/venv --system-site-packages && \
/opt/venv/bin/pip install --no-cache-dir --prefer-binary --only-binary=:all: \
weasyprint pdf2image opencv-python-headless ocrmypdf \
cryptography \
"cryptography>=48.0.1" \
"lxml>=6.1.0" \
"unoserver==${UNOSERVER_VERSION}" && \
find /opt/venv -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true && \
find /opt/venv \( -name '*.pyc' -o -name '*.pyi' \) -delete 2>/dev/null || true && \
@@ -364,7 +365,7 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
# Final runtime image - the actual base image
FROM eclipse-temurin:25-jre-noble@sha256:b27ca47660a8fa837e47a8533b9b1a3a430295cf29ca28d91af4fd121572dc29 AS runtime
FROM eclipse-temurin:25-jre-noble@sha256:f9bd8815e73632c22985ebb133ec49b9fc4ad5ffe0657594ac02748ad0431ab7 AS runtime
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
@@ -382,6 +383,9 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get install -y --no-install-recommends software-properties-common; \
add-apt-repository -y ppa:libreoffice/ppa; \
apt-get update; \
# Security: upgrade base-image packages to latest noble patches (reduces deb CVEs
# in openssl, perl, libxml2, gnutls, cups, curl, etc. inherited from the runtime base)
apt-get upgrade -y; \
apt-get install -y --no-install-recommends \
# Core tools
ca-certificates tzdata tini bash fontconfig curl \
+2 -2
View File
@@ -1,7 +1,7 @@
# Stirling-PDF - Full version (embedded frontend)
# Uses pre-built base image for fast builds
ARG BASE_VERSION=1.0.2
ARG BASE_VERSION=1.0.4
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
# Stage 1: Build the Java application and frontend
@@ -59,7 +59,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
--no-daemon
# Stage 2: Extract Spring Boot Layers
FROM eclipse-temurin:25-jre-noble@sha256:b27ca47660a8fa837e47a8533b9b1a3a430295cf29ca28d91af4fd121572dc29 AS jar-extract
FROM eclipse-temurin:25-jre-noble@sha256:f9bd8815e73632c22985ebb133ec49b9fc4ad5ffe0657594ac02748ad0431ab7 AS jar-extract
WORKDIR /tmp
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
+1 -1
View File
@@ -2,7 +2,7 @@
# Extra fonts for air-gapped environments
# Uses pre-built base image for fast builds
ARG BASE_VERSION=1.0.2
ARG BASE_VERSION=1.0.4
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
# Stage 1: Build the Java application and frontend
+2 -1
View File
@@ -55,7 +55,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
# Stage 2: Runtime image
# glibc base (not Alpine/musl): JPDFium's PDFium natives are glibc-linked.
FROM eclipse-temurin:25-jre-noble@sha256:b27ca47660a8fa837e47a8533b9b1a3a430295cf29ca28d91af4fd121572dc29
FROM eclipse-temurin:25-jre-noble@sha256:f9bd8815e73632c22985ebb133ec49b9fc4ad5ffe0657594ac02748ad0431ab7
ENV DEBIAN_FRONTEND=noninteractive \
LANG=C.UTF-8 \
@@ -99,6 +99,7 @@ ENV VERSION_TAG=$VERSION_TAG \
RUN mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /storage /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps && \
mkdir -p /usr/share/fonts/opentype/noto && \
apt-get update && \
apt-get upgrade -y && \
apt-get install -y --no-install-recommends \
ca-certificates \
tzdata \
@@ -6240,10 +6240,6 @@ muted = "muted"
nameLabel = "Name"
namePlaceholder = "e.g. Compliance escalation"
[portal.agentBuilder.status]
draft = "Draft"
published = "Published"
[portal.agentBuilder.tabs]
evals = "Evals"
scenarios = "Scenarios"
@@ -6467,7 +6463,7 @@ install = "Install"
usage = "Usage"
[portal.catalogue.detail.locked]
description = "{{name}} is included from the {{plan}}. Upgrade to embed it."
description = "{{name}} is included from the {{tier}} plan. Upgrade to embed it."
title = "Not available on your plan"
[portal.catalogue.detail.preview]
@@ -6534,35 +6530,6 @@ 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"
@@ -7374,6 +7341,10 @@ 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"
@@ -7444,7 +7415,6 @@ 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"
@@ -8016,29 +7986,34 @@ primaryNav = "Primary navigation"
switchApp = "Switch app"
[portal.sources]
subtitle = "Reusable input connections that feed documents into Stirling. Configure a source once, then reference it from any number of pipelines."
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."
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"
@@ -8054,15 +8029,10 @@ 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"
@@ -8110,10 +8080,6 @@ 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"
@@ -8139,48 +8105,25 @@ placeholder = "us-east-1"
[portal.sources.types.s3.fields.secretAccessKey]
label = "Secret access key"
[portal.sources.types.webhook]
description = "Receive documents by signed HTTP POST from any external system."
label = "Webhook"
[portal.sources.types.webhook.detail]
deliveryUrl = "Delivery URL"
secretNote = "The signing secret is shown once, when the webhook is created. Recreate the source to roll it."
[portal.sources.types.webhook.fields.connection]
helperText = "Choose an S3 connection to stage deliveries durably (required in hosted). Leave blank on a self-hosted server to stage them on local disk."
label = "Storage connection"
[portal.sources.types.webhook.fields.mode]
helperText = "Consume removes each delivered document once every policy has processed it."
label = "Read mode"
[portal.sources.types.webhook.fields.mode.options]
consume = "Consume: process each delivery once"
snapshot = "Snapshot: re-read the spool every run"
[portal.sources.types.webhook.reveal]
copy = "Copy"
done = "Done"
secret = "Signing secret"
secretHelp = "Sign each delivery's raw body with this key (HMAC-SHA256) and send it as the X-Stirling-Signature header."
secretWarning = "Copy the signing secret now. For your security it is shown only once and cannot be retrieved later."
title = "Webhook created"
url = "Delivery URL"
usage = "POST each document as the raw request body to the delivery URL with a binary content type (application/pdf or application/octet-stream). Referencing policies run automatically on arrival."
[portal.sources.types.unknown]
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.tier]
enterprise = "Enterprise plan"
free = "Editor plan"
pro = "Processor plan"
[portal.sources.wizard.steps]
chooseType = "Choose type"
configure = "Configure"
review = "Review & connect"
[portal.usage]
managePayment = "Manage Payment"
@@ -10196,19 +10139,6 @@ 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"
@@ -10279,9 +10209,7 @@ by = "Invited by {{who}}"
cancel = "Cancel"
count = "{{count}} pending"
desc = "Invited people who haven't joined yet. They hold a seat until they accept."
expiresInDays_one = "Expires in {{count}} day"
expiresInDays_other = "Expires in {{count}} days"
expiresToday = "Expires today"
expires = "Expires"
title = "Pending invitations"
[users.loadError]
+3 -28
View File
@@ -270,29 +270,8 @@
--grad-banner: linear-gradient(135deg, #0f172a 0%, #111827 50%, #1a1535 100%);
}
/* 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"] {
/* Always-dark code palette. Not theme-switched. */
:root {
--code-bg: #0f172a;
--code-bg-alt: #1e293b;
--code-bg-header: #1a2332;
@@ -307,16 +286,12 @@
--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;
+6 -9
View File
@@ -61,14 +61,11 @@
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.mantine-Button-root[data-disabled]:not([data-loading]) {
background: var(--color-bg-muted);
color: var(--color-text-5);
border-color: transparent;
opacity: 1;
.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;
}
+1 -2
View File
@@ -51,8 +51,7 @@
transition: background var(--motion-fast);
}
.sui-code__copy:hover {
/* Subtle tint that reads on both the light and dark code surfaces. */
background: color-mix(in srgb, var(--code-text) 8%, transparent);
background: rgba(255, 255, 255, 0.05);
}
.sui-code__pre {
margin: 0;
+31 -42
View File
@@ -19,12 +19,6 @@ 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;
@@ -41,7 +35,6 @@ export function Table<T>({
rows,
rowKey,
onRowClick,
isRowInteractive,
empty,
className,
}: TableProps<T>) {
@@ -73,42 +66,38 @@ export function Table<T>({
</td>
</tr>
) : (
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);
}
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);
}
: 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>
@@ -7,15 +7,6 @@ describe("creatableSourceTypes (SaaS)", () => {
expect(creatableSourceTypes().map((t) => t.type)).not.toContain("folder");
});
it("offers webhook with a required S3 connection (durable staging in hosted)", () => {
const webhook = creatableSourceTypes().find((t) => t.type === "webhook");
expect(webhook).toBeDefined();
const connection = webhook?.fields.find(
(f) => f.control === "s3Connection",
);
expect(connection?.required).toBe(true);
});
it("still offers the cloud source types", () => {
expect(creatableSourceTypes().map((t) => t.type)).toContain("s3");
});
@@ -1,25 +1,13 @@
import {
CREATABLE_SOURCE_TYPES,
WEBHOOK_SOURCE_TYPE,
type CreatableSourceType,
} from "@portal/components/sources/sourceTypes";
/** Hosted drops folder (no server FS) and requires webhook's S3 connection (node-local isn't durable). */
/**
* Hosted deployments never read the server's filesystem (the backend's
* FolderAccessGuard denies it outright), so folder connections are not offered
* in the connect wizard.
*/
export function creatableSourceTypes(): CreatableSourceType[] {
return CREATABLE_SOURCE_TYPES.filter((type) => type.type !== "folder").map(
(type) =>
type.type === WEBHOOK_SOURCE_TYPE ? withRequiredConnection(type) : type,
);
}
/** The type with its S3-connection field marked required (durable staging is mandatory). */
function withRequiredConnection(
type: CreatableSourceType,
): CreatableSourceType {
return {
...type,
fields: type.fields.map((field) =>
field.control === "s3Connection" ? { ...field, required: true } : field,
),
};
return CREATABLE_SOURCE_TYPES.filter((type) => type.type !== "folder");
}
@@ -5,7 +5,6 @@ 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";
@@ -37,14 +36,6 @@ 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 />}
-5
View File
@@ -99,11 +99,6 @@ 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
@@ -1,74 +0,0 @@
/**
* 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" },
);
}
+2 -17
View File
@@ -9,7 +9,6 @@
* 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";
@@ -557,30 +556,17 @@ 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: policyDisplayName(entry, t),
name: `${entry.category.label} Policy`,
enabled,
categoryId: entry.category.id,
sources: result.sources,
@@ -603,14 +589,13 @@ 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: policyDisplayName(entry, t),
name: `${entry.category.label} Policy`,
enabled,
categoryId: entry.category.id,
sources: s.sources,
@@ -32,8 +32,6 @@ export interface SourceView {
docsTotal: number;
docs24h: number;
docs30d: number;
/** Webhook sources only: the server-relative delivery path senders POST to. Null otherwise. */
webhookPath?: string | null;
}
export interface SourceKpi {
+9 -14
View File
@@ -1,7 +1,3 @@
// 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";
@@ -273,23 +269,22 @@ 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 i18n.t("users.activity.never");
if (value === undefined || value === null) return "Never";
const ts = typeof value === "string" ? Date.parse(value) : value;
if (!Number.isFinite(ts) || ts <= 0) return i18n.t("users.activity.never");
if (!Number.isFinite(ts) || ts <= 0) return "Never";
const mins = Math.max(0, Math.round((Date.now() - ts) / 60000));
if (mins < 1) return i18n.t("users.activity.justNow");
if (mins < 60) return i18n.t("users.activity.minutesAgo", { count: mins });
if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.round(mins / 60);
if (hours < 24) return i18n.t("users.activity.hoursAgo", { count: hours });
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
if (days < 7) return i18n.t("users.activity.daysAgo", { count: days });
if (days < 7) return `${days}d ago`;
const weeks = Math.round(days / 7);
if (weeks < 5) return i18n.t("users.activity.weeksAgo", { count: weeks });
if (weeks < 5) return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`;
const months = Math.round(days / 30);
if (months < 12) return i18n.t("users.activity.monthsAgo", { count: months });
if (months < 12) return months <= 1 ? "1 month ago" : `${months} months ago`;
const years = Math.round(days / 365);
return i18n.t("users.activity.yearsAgo", { count: years });
return years <= 1 ? "1 year ago" : `${years} years ago`;
}
/** 0 / huge sentinel license values mean "no seat limit". */
@@ -15,7 +15,7 @@
align-items: center;
gap: 1.25rem;
padding: 1rem 1.25rem;
background: var(--color-hero-navy);
background: #16213e;
}
.portal-editor-hero__logo {
@@ -123,7 +123,7 @@
.portal-editor-hero__action .portal-editor-hero__cta.sui-btn {
background: #ffffff;
border-color: #ffffff;
color: var(--color-hero-navy);
color: #16213e;
}
.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
/>
{t(TIER_INFO[tier].labelKey)}
{TIER_INFO[tier].label}
</span>
<span className="portal-statusstrip__sep" aria-hidden>
·
@@ -18,7 +18,7 @@
gap: 1rem;
flex-wrap: wrap;
padding: 0.875rem 1.25rem;
background: var(--color-hero-navy);
background: #16213e;
}
.portal-welcome__brand {
@@ -90,7 +90,7 @@
.portal-welcome__header .portal-welcome__cta.sui-btn {
background: #ffffff;
border-color: #ffffff;
color: var(--color-hero-navy);
color: #16213e;
}
.portal-welcome__header .portal-welcome__cta.sui-btn:hover {
background: rgba(255, 255, 255, 0.88);
@@ -1,11 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { StatusBadge, Tabs, type TabItem } from "@app/ui";
import {
type Agent,
AGENT_STATUS_LABEL,
AGENT_STATUS_TONE,
} from "@portal/api/agents";
import { type Agent, 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";
@@ -56,7 +52,7 @@ export function AgentBuilderPanel({
</div>
<div className="portal-agents__builder-meta">
<StatusBadge tone={AGENT_STATUS_TONE[agent.status]} size="sm">
{t(AGENT_STATUS_LABEL[agent.status])}
{agent.status}
</StatusBadge>
<code className="portal-agents__builder-version">
{agent.version}
@@ -1,9 +1,5 @@
import { useTranslation } from "react-i18next";
import {
type Agent,
AGENT_STATUS_LABEL,
AGENT_STATUS_TONE,
} from "@portal/api/agents";
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
import { Button, StatusBadge } from "@app/ui";
import "@portal/views/AgentBuilder.css";
@@ -44,7 +40,7 @@ export function AgentSelector({
</span>
<span className="portal-agents__selector-meta">
<StatusBadge tone={AGENT_STATUS_TONE[a.status]} size="sm">
{t(AGENT_STATUS_LABEL[a.status])}
{a.status}
</StatusBadge>
<code className="portal-agents__selector-version">{a.version}</code>
</span>
@@ -1,10 +1,6 @@
import { useTranslation } from "react-i18next";
import { Button, StatusBadge } from "@app/ui";
import {
type Agent,
AGENT_STATUS_LABEL,
AGENT_STATUS_TONE,
} from "@portal/api/agents";
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
import "@portal/views/AgentBuilder.css";
interface VersionsPanelProps {
@@ -56,7 +52,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) {
{v.version}
</code>
<StatusBadge tone={AGENT_STATUS_TONE[v.status]} size="sm">
{t(AGENT_STATUS_LABEL[v.status])}
{v.status}
</StatusBadge>
{isCurrent && (
<StatusBadge tone="info" size="sm" showDot={false}>
@@ -12,11 +12,9 @@ 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";
@@ -115,7 +113,7 @@ export function ComponentDetailModal({
title={t("portal.catalogue.detail.locked.title")}
description={t("portal.catalogue.detail.locked.description", {
name: component.name,
plan: t(TIER_INFO[component.minTier].labelKey),
tier: component.minTier,
})}
/>
)}
@@ -207,7 +205,7 @@ export function ComponentDetailModal({
/>
<StatTile
label={t("portal.catalogue.detail.stats.billedOn")}
value={t(BILLING_UNIT_LABEL[component.pricing.unit])}
value={component.pricing.unit}
/>
<StatTile
label={t("portal.catalogue.detail.stats.freeQuota")}
@@ -222,7 +220,7 @@ export function ComponentDetailModal({
</div>
<p className="portal-components__pricing-note">
{t("portal.catalogue.detail.pricing.note", {
unit: t(BILLING_UNIT_LABEL[component.pricing.unit]),
unit: 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">
{t(KEY_LABEL[apiKey.status])}
{KEY_LABEL[apiKey.status]}
</StatusBadge>
<span
className={"portal-infra__chevron" + (open ? " is-open" : "")}
@@ -75,7 +75,7 @@ export function DocumentLedger({
className="portal-proc__stage-label"
data-current={cur || undefined}
>
{t(group.label)}
{group.label}
</span>
{blurb && (
<span className="portal-proc__stage-hint">
@@ -0,0 +1,16 @@
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 } };
@@ -0,0 +1,192 @@
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();
});
});
@@ -0,0 +1,298 @@
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>
);
}
@@ -1,82 +0,0 @@
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();
});
});
@@ -1,186 +0,0 @@
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>
);
}
@@ -1,116 +0,0 @@
import { useTranslation } from "react-i18next";
import { FormField, Input } from "@app/ui";
/**
* The connection-level S3 fields (per-use settings like prefix/mode live on the
* source or output referencing the connection). Secrets are write-only: when
* editing, the backend returns them masked and keeps the stored value if the
* mask is sent back unchanged.
*/
export interface S3ConnectionFormValues {
name: string;
bucket: string;
region: string;
endpoint: string;
accessKeyId: string;
secretAccessKey: string;
}
export const EMPTY_S3_CONNECTION: S3ConnectionFormValues = {
name: "",
bucket: "",
region: "us-east-1",
endpoint: "",
accessKeyId: "",
secretAccessKey: "",
};
export function s3ConnectionRequestConfig(
values: S3ConnectionFormValues,
): Record<string, unknown> {
return {
bucket: values.bucket.trim(),
region: values.region.trim(),
endpoint: values.endpoint.trim(),
accessKeyId: values.accessKeyId.trim(),
secretAccessKey: values.secretAccessKey,
};
}
export function s3ConnectionFormValid(values: S3ConnectionFormValues): boolean {
return (
values.name.trim() !== "" &&
values.bucket.trim() !== "" &&
values.accessKeyId.trim() !== "" &&
values.secretAccessKey.trim() !== ""
);
}
interface S3ConnectionFormProps {
values: S3ConnectionFormValues;
onChange: (values: S3ConnectionFormValues) => void;
}
export function S3ConnectionForm({ values, onChange }: S3ConnectionFormProps) {
const { t } = useTranslation();
const set = (key: keyof S3ConnectionFormValues, value: string) =>
onChange({ ...values, [key]: value });
return (
<div className="portal-sources__connection-form">
<FormField label={t("portal.connections.s3.fields.name")} required>
<Input
value={values.name}
placeholder={t("portal.connections.s3.fields.namePlaceholder")}
onChange={(e) => set("name", e.target.value)}
/>
</FormField>
<FormField
label={t("portal.sources.types.s3.fields.bucket.label")}
required
>
<Input
value={values.bucket}
placeholder="my-company-inbox"
onChange={(e) => set("bucket", e.target.value)}
/>
</FormField>
<FormField label={t("portal.sources.types.s3.fields.region.label")}>
<Input
value={values.region}
placeholder="us-east-1"
onChange={(e) => set("region", e.target.value)}
/>
</FormField>
<FormField
label={t("portal.sources.types.s3.fields.accessKeyId.label")}
required
>
<Input
value={values.accessKeyId}
onChange={(e) => set("accessKeyId", e.target.value)}
/>
</FormField>
<FormField
label={t("portal.sources.types.s3.fields.secretAccessKey.label")}
required
>
<Input
type="password"
value={values.secretAccessKey}
onChange={(e) => set("secretAccessKey", e.target.value)}
/>
</FormField>
<FormField
label={t("portal.sources.types.s3.fields.endpoint.label")}
helperText={t("portal.sources.types.s3.fields.endpoint.helperText")}
>
<Input
value={values.endpoint}
placeholder="https://s3.example.com"
onChange={(e) => set("endpoint", e.target.value)}
/>
</FormField>
</div>
);
}
@@ -1,131 +0,0 @@
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 { S3ConnectionModal } from "@portal/components/sources/S3ConnectionModal";
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 createIntegration = vi.fn();
const updateIntegration = vi.fn();
vi.mock("@portal/api/integrations", () => ({
createIntegration: (...a: unknown[]) => createIntegration(...a),
updateIntegration: (...a: unknown[]) => updateIntegration(...a),
}));
function setField(labelPattern: RegExp, value: string) {
fireEvent.change(screen.getByLabelText(labelPattern), { target: { value } });
}
describe("S3ConnectionModal", () => {
beforeEach(() => {
createIntegration.mockReset();
updateIntegration.mockReset();
});
it("creates a team-scoped connection from the entered fields", async () => {
createIntegration.mockResolvedValue({ id: 5, name: "Claims bucket" });
const onSaved = vi.fn();
const onClose = vi.fn();
render(<S3ConnectionModal open onClose={onClose} onSaved={onSaved} />);
setField(/portal\.connections\.s3\.fields\.name/, "Claims bucket");
setField(/portal\.sources\.types\.s3\.fields\.bucket\.label/, "inbox");
setField(/portal\.sources\.types\.s3\.fields\.accessKeyId\.label/, "AKIA");
setField(
/portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/,
"shh",
);
fireEvent.click(screen.getByText("portal.connections.picker.save"));
await waitFor(() => expect(createIntegration).toHaveBeenCalledTimes(1));
expect(createIntegration).toHaveBeenCalledWith({
integrationType: "S3",
name: "Claims bucket",
scope: "TEAM",
config: {
bucket: "inbox",
region: "us-east-1",
endpoint: "",
accessKeyId: "AKIA",
secretAccessKey: "shh",
},
});
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));
expect(onClose).toHaveBeenCalled();
});
it("round-trips a masked secret unchanged on edit (keeps the stored value)", async () => {
updateIntegration.mockResolvedValue({ id: 5, name: "Claims bucket" });
// The API returns secrets masked; the modal must resend the sentinel verbatim
// so the backend keeps the stored secret rather than overwriting it.
const connection = {
id: 5,
integrationType: "S3",
name: "Claims bucket",
config: {
bucket: "inbox",
region: "us-east-1",
accessKeyId: "AKIA",
secretAccessKey: "********",
},
canManage: true,
} as unknown as IntegrationConfig;
render(
<S3ConnectionModal
open
connection={connection}
onClose={vi.fn()}
onSaved={vi.fn()}
/>,
);
const secret = screen.getByLabelText(
/portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/,
) as HTMLInputElement;
expect(secret.value).toBe("********");
// Change only the name; leave the masked secret untouched.
setField(/portal\.connections\.s3\.fields\.name/, "Renamed bucket");
fireEvent.click(screen.getByText("portal.connections.picker.save"));
await waitFor(() => expect(updateIntegration).toHaveBeenCalledTimes(1));
expect(updateIntegration).toHaveBeenCalledWith(
5,
expect.objectContaining({
name: "Renamed bucket",
config: expect.objectContaining({ secretAccessKey: "********" }),
}),
);
});
it("keeps save disabled until the required fields are present", () => {
render(<S3ConnectionModal open onClose={vi.fn()} onSaved={vi.fn()} />);
const save = () =>
screen.getByText("portal.connections.picker.save").closest("button");
expect(save()).toBeDisabled();
setField(/portal\.connections\.s3\.fields\.name/, "Only a name");
expect(save()).toBeDisabled();
setField(/portal\.sources\.types\.s3\.fields\.bucket\.label/, "inbox");
setField(/portal\.sources\.types\.s3\.fields\.accessKeyId\.label/, "AKIA");
setField(
/portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/,
"shh",
);
expect(save()).not.toBeDisabled();
});
});
@@ -1,127 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Banner, Button, Modal } from "@app/ui";
import { errorMessage } from "@portal/api/http";
import {
createIntegration,
updateIntegration,
type IntegrationConfig,
} from "@portal/api/integrations";
import {
EMPTY_S3_CONNECTION,
S3ConnectionForm,
s3ConnectionFormValid,
s3ConnectionRequestConfig,
type S3ConnectionFormValues,
} from "@portal/components/sources/S3ConnectionForm";
/**
* The one place S3 connections are created and edited. Launched from the
* Connections tab, the source builder's connection picker, and the pipeline
* builder output - so connection setup is always a modal, never inline splat.
* Saving validates backend-side (schema, SSRF, credentials); on edit the secret
* arrives masked and round-trips unchanged to keep the stored value.
*/
interface S3ConnectionModalProps {
open: boolean;
/** When set, edit this connection; otherwise create a new one. */
connection?: IntegrationConfig | null;
onClose: () => void;
/** The saved connection, so callers can select or refresh it. */
onSaved: (connection: IntegrationConfig) => void;
}
export function S3ConnectionModal({
open,
connection,
onClose,
onSaved,
}: S3ConnectionModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<S3ConnectionFormValues>(EMPTY_S3_CONNECTION);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEdit = Boolean(connection);
// Seed the form each time the modal opens (or its target changes).
useEffect(() => {
if (!open) return;
if (connection) {
const config = connection.config ?? {};
setForm({
name: connection.name,
bucket: String(config.bucket ?? ""),
region: String(config.region ?? "us-east-1"),
endpoint: String(config.endpoint ?? ""),
accessKeyId: String(config.accessKeyId ?? ""),
secretAccessKey: String(config.secretAccessKey ?? ""),
});
} else {
setForm(EMPTY_S3_CONNECTION);
}
setError(null);
}, [open, connection]);
async function save() {
if (saving || !s3ConnectionFormValid(form)) return;
setSaving(true);
setError(null);
try {
const saved = connection
? await updateIntegration(connection.id, {
name: form.name.trim(),
config: s3ConnectionRequestConfig(form),
})
: // TEAM scope suits the team-based portal (the backend defaults the team to the
// caller's own). A teamless single-operator self-hosted deployment would need a
// USER/SERVER scope choice here - follow-up if the portal ships there.
await createIntegration({
integrationType: "S3",
name: form.name.trim(),
scope: "TEAM",
config: s3ConnectionRequestConfig(form),
});
onSaved(saved);
onClose();
} catch (e) {
setError(errorMessage(e));
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
title={t(
isEdit
? "portal.connections.editTitle"
: "portal.connections.createTitle",
)}
footer={
<div className="portal-sources__connection-create-actions">
<Button
variant="tertiary"
size="sm"
disabled={saving}
onClick={onClose}
>
{t("portal.connections.picker.cancel")}
</Button>
<Button
size="sm"
loading={saving}
disabled={!s3ConnectionFormValid(form)}
onClick={() => void save()}
>
{t("portal.connections.picker.save")}
</Button>
</div>
}
>
<S3ConnectionForm values={form} onChange={setForm} />
{error && <Banner tone="danger" description={error} />}
</Modal>
);
}
@@ -1,72 +0,0 @@
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 { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker";
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 createIntegration = vi.fn();
vi.mock("@portal/api/integrations", () => ({
fetchS3Connections: () => fetchS3Connections(),
createIntegration: (...a: unknown[]) => createIntegration(...a),
updateIntegration: vi.fn(),
}));
describe("S3ConnectionPicker", () => {
beforeEach(() => {
fetchS3Connections.mockReset();
fetchS3Connections.mockResolvedValue([]);
createIntegration.mockReset();
});
it("creates a connection inline and selects it", async () => {
createIntegration.mockResolvedValue({ id: 7, name: "New bucket" });
const onChange = vi.fn();
render(<S3ConnectionPicker value="" onChange={onChange} />);
fireEvent.click(
await screen.findByText("portal.connections.picker.createNew"),
);
fireEvent.change(
screen.getByLabelText(/portal\.connections\.s3\.fields\.name/),
{ target: { value: "New bucket" } },
);
fireEvent.change(
screen.getByLabelText(
/portal\.sources\.types\.s3\.fields\.bucket\.label/,
),
{ target: { value: "inbox" } },
);
fireEvent.change(
screen.getByLabelText(
/portal\.sources\.types\.s3\.fields\.accessKeyId\.label/,
),
{ target: { value: "AKIA" } },
);
fireEvent.change(
screen.getByLabelText(
/portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/,
),
{ target: { value: "shh" } },
);
fireEvent.click(screen.getByText("portal.connections.picker.save"));
await waitFor(() => expect(createIntegration).toHaveBeenCalledTimes(1));
// The newly created connection's id is selected in the parent.
await waitFor(() => expect(onChange).toHaveBeenCalledWith("7"));
});
});
@@ -1,71 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Banner, Button, Select } from "@app/ui";
import { errorMessage } from "@portal/api/http";
import {
fetchS3Connections,
type IntegrationConfig,
} from "@portal/api/integrations";
import { S3ConnectionModal } from "@portal/components/sources/S3ConnectionModal";
/**
* Selects a stored S3 connection by id. Creating a new one opens the shared
* connection modal (saved immediately and validated backend-side), so the
* parent only ever sees a real connection id.
*/
interface S3ConnectionPickerProps {
value: string;
onChange: (connectionId: string) => void;
}
export function S3ConnectionPicker({
value,
onChange,
}: S3ConnectionPickerProps) {
const { t } = useTranslation();
const [connections, setConnections] = useState<IntegrationConfig[] | null>(
null,
);
const [modalOpen, setModalOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
fetchS3Connections()
.then((list) => {
if (mounted) setConnections(list);
})
.catch((e) => {
if (mounted) setError(errorMessage(e));
});
return () => {
mounted = false;
};
}, []);
return (
<div className="portal-sources__connection-picker">
<Select
value={value || null}
placeholder={t("portal.connections.picker.placeholder")}
options={(connections ?? []).map((connection) => ({
value: String(connection.id),
label: connection.name,
}))}
onChange={(selected) => onChange(selected ?? "")}
/>
<Button variant="tertiary" size="sm" onClick={() => setModalOpen(true)}>
{t("portal.connections.picker.createNew")}
</Button>
{error && <Banner tone="danger" description={error} />}
<S3ConnectionModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onSaved={(created) => {
setConnections((list) => [...(list ?? []), created]);
onChange(String(created.id));
}}
/>
</div>
);
}
@@ -0,0 +1,63 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { SourceView } from "@portal/api/sources";
import { SourceDetailCard } from "@portal/components/sources/SourceDetailCard";
import { sampleDailySeries } from "@portal/mocks/sampleDailySeries";
const SAMPLE_SERIES = sampleDailySeries(330);
const IN_USE: SourceView = {
id: "src-claims",
name: "Claims intake",
type: "folder",
status: "active",
referenceCount: 2,
referencingPolicies: [
{ id: "a", name: "Security Policy" },
{ id: "b", name: "Redaction Policy" },
],
config: [
{ label: "Directory", value: "/data/claims-intake" },
{ label: "Mode", value: "consume" },
],
docsTotal: 45230,
docs24h: 312,
docs30d: 9870,
};
const ORPHANED: SourceView = {
id: "src-archive",
name: "Archive reprocess",
type: "folder",
status: "unused",
referenceCount: 0,
referencingPolicies: [],
config: [{ label: "Directory", value: "/data/archive" }],
docsTotal: 45230,
docs24h: 312,
docs30d: 9870,
};
const meta: Meta<typeof SourceDetailCard> = {
title: "Portal/Sources/SourceDetailCard",
component: SourceDetailCard,
parameters: { layout: "padded" },
args: {
docSeries: SAMPLE_SERIES,
onClose: () => {},
onEdit: () => {},
onTogglePause: () => {},
onDelete: () => {},
},
decorators: [
(S) => (
<div style={{ maxWidth: "56rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SourceDetailCard>;
export const InUse: Story = { args: { source: IN_USE } };
export const Orphaned: Story = { args: { source: ORPHANED } };
@@ -0,0 +1,100 @@
import { useTranslation } from "react-i18next";
import { ActionIcon, Button } from "@app/ui";
import type { SourceView } from "@portal/api/sources";
import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel";
import {
EDITOR_SOURCE_TYPE,
sourceTypeMeta,
} from "@portal/components/sources/sourceTypes";
import "@portal/views/Sources.css";
import CloseIcon from "@mui/icons-material/Close";
interface SourceDetailCardProps {
source: SourceView;
docSeries: number[];
onClose: () => void;
onEdit: (source: SourceView) => void;
onTogglePause: (source: SourceView) => void;
onDelete: (source: SourceView) => void;
/** Disables the actions while a mutation is in flight. */
busy?: boolean;
}
/** Expanded detail for the selected source row, with edit/pause/delete actions. */
export function SourceDetailCard({
source,
docSeries,
onClose,
onEdit,
onTogglePause,
onDelete,
busy = false,
}: SourceDetailCardProps) {
const { t } = useTranslation();
const meta = sourceTypeMeta(source.type);
const paused = source.status === "disabled";
// The editor is a built-in source: it has no instance name and can't be edited/paused/deleted.
const isEditor = source.type === EDITOR_SOURCE_TYPE;
return (
<section className="portal-sources__expanded">
<header className="portal-sources__expanded-head">
<span
className={`portal-sources__type-dot portal-sources__type-dot--${meta.accent}`}
aria-hidden
>
{meta.icon}
</span>
<div>
<h2 className="portal-sources__expanded-title">
{isEditor ? t(meta.labelKey) : source.name}
</h2>
<span className="portal-sources__expanded-sub">
{t("portal.sources.detail.subtitle", {
type: t(meta.labelKey),
status: t(`portal.sources.status.${source.status}`),
})}
</span>
</div>
<ActionIcon
variant="tertiary"
className="portal-sources__expanded-close"
onClick={onClose}
aria-label={t("portal.sources.detail.closeAriaLabel")}
>
<CloseIcon />
</ActionIcon>
</header>
<SourceDetailPanel source={source} docSeries={docSeries} />
{!isEditor && (
<div className="portal-sources__detail-actions">
<Button
variant="secondary"
disabled={busy}
onClick={() => onEdit(source)}
>
{t("portal.sources.detail.edit")}
</Button>
<Button
variant="secondary"
disabled={busy}
onClick={() => onTogglePause(source)}
>
{paused
? t("portal.sources.detail.resume")
: t("portal.sources.detail.pause")}
</Button>
<Button
accent="danger"
variant="secondary"
disabled={busy}
onClick={() => onDelete(source)}
>
{t("portal.sources.detail.delete")}
</Button>
</div>
)}
</section>
);
}
@@ -0,0 +1,59 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { SourceView } from "@portal/api/sources";
import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel";
import { sampleDailySeries } from "@portal/mocks/sampleDailySeries";
const SAMPLE_SERIES = sampleDailySeries(330);
const IN_USE: SourceView = {
id: "src-claims",
name: "Claims intake",
type: "folder",
status: "active",
referenceCount: 2,
referencingPolicies: [
{ id: "a", name: "Security Policy" },
{ id: "b", name: "Redaction Policy" },
],
config: [
{ label: "Directory", value: "/data/claims-intake" },
{ label: "Mode", value: "consume" },
],
docsTotal: 45230,
docs24h: 312,
docs30d: 9870,
};
const ORPHANED: SourceView = {
id: "src-archive",
name: "Archive reprocess",
type: "folder",
status: "unused",
referenceCount: 0,
referencingPolicies: [],
config: [{ label: "Directory", value: "/data/archive" }],
docsTotal: 1180,
docs24h: 0,
docs30d: 0,
};
const meta: Meta<typeof SourceDetailPanel> = {
title: "Portal/Sources/SourceDetailPanel",
component: SourceDetailPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "48rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SourceDetailPanel>;
export const InUse: Story = {
args: { source: IN_USE, docSeries: SAMPLE_SERIES },
};
/** A source no policy references is called out as safe to delete. */
export const Orphaned: Story = { args: { source: ORPHANED, docSeries: [] } };
@@ -0,0 +1,91 @@
import { useTranslation } from "react-i18next";
import { Chip, StatTile } from "@app/ui";
import type { SourceView } from "@portal/api/sources";
import { Sparkline } from "@portal/components/sources/Sparkline";
import { EDITOR_SOURCE_TYPE } from "@portal/components/sources/sourceTypes";
import "@portal/views/Sources.css";
interface SourceDetailPanelProps {
source: SourceView;
/** The 30-day daily series for the sparkline, fetched per source when expanded. */
docSeries: number[];
}
/**
* Expanded detail for a source row: its config (key/value), the documents it has
* fed into runs, and which policies reference it (a 0-reference source is called
* out as safe to delete).
*/
export function SourceDetailPanel({
source,
docSeries,
}: SourceDetailPanelProps) {
const { t } = useTranslation();
const isEditor = source.type === EDITOR_SOURCE_TYPE;
return (
<div className="portal-sources__detail">
{isEditor && (
<p className="portal-sources__muted">
{t("portal.sources.types.editor.description")}
</p>
)}
{source.config.length > 0 && (
<div className="portal-sources__stat-grid">
{source.config.map((row) => (
<StatTile key={row.label} label={row.label} value={row.value} />
))}
</div>
)}
<div className="portal-sources__detail-section">
<span className="portal-sources__detail-heading">
{t("portal.sources.detail.usedBy")}
</span>
{source.referencingPolicies.length === 0 ? (
<p className="portal-sources__muted">
{t(
isEditor
? "portal.sources.types.editor.noPolicies"
: "portal.sources.detail.notReferenced",
)}
</p>
) : (
<div className="portal-sources__chips">
{source.referencingPolicies.map((policy) => (
<Chip key={policy.id} size="sm">
{policy.name}
</Chip>
))}
</div>
)}
</div>
<div className="portal-sources__detail-section">
<span className="portal-sources__detail-heading">
{t("portal.sources.detail.documents")}
</span>
<div className="portal-sources__stat-grid">
<StatTile
label={t("portal.sources.detail.docsTotal")}
value={source.docsTotal.toLocaleString()}
/>
<StatTile
label={t("portal.sources.detail.docs24h")}
value={source.docs24h.toLocaleString()}
/>
<StatTile
label={t("portal.sources.detail.docs30d")}
value={source.docs30d.toLocaleString()}
/>
</div>
{docSeries.length > 0 && (
<Sparkline
data={docSeries}
ariaLabel={t("portal.sources.detail.docsTrend")}
/>
)}
</div>
</div>
);
}
@@ -51,9 +51,14 @@ const meta: Meta<typeof SourcesTable> = {
title: "Portal/Sources/SourcesTable",
component: SourcesTable,
parameters: { layout: "padded" },
args: { sources: SOURCES, onRowClick: () => {} },
args: { sources: SOURCES, expandedId: null, onRowClick: () => {} },
};
export default meta;
type Story = StoryObj<typeof SourcesTable>;
export const Default: Story = {};
/** A row with an open detail panel rotates its caret. */
export const RowExpanded: Story = {
args: { expandedId: SOURCES[0].id },
};
@@ -1,6 +1,5 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded";
import {
Chip,
StatusBadge,
@@ -23,11 +22,16 @@ const STATUS_TONE: Record<SourceStatus, StatusTone> = {
interface SourcesTableProps {
sources: SourceView[];
/** Opens a source's own page. Not called for the virtual editor row. */
/** Id of the row whose detail panel is open, drives the caret state. */
expandedId: string | null;
onRowClick: (source: SourceView) => void;
}
export function SourcesTable({ sources, onRowClick }: SourcesTableProps) {
export function SourcesTable({
sources,
expandedId,
onRowClick,
}: SourcesTableProps) {
const { t } = useTranslation();
const columns = useMemo<TableColumn<SourceView>[]>(
() => [
@@ -72,18 +76,6 @@ export function SourcesTable({ sources, onRowClick }: SourcesTableProps) {
</StatusBadge>
),
},
{
key: "docs",
header: t("portal.sources.table.documents"),
align: "right",
render: (s) => (
<span
className={s.docsTotal === 0 ? "portal-sources__muted" : undefined}
>
{s.docsTotal.toLocaleString()}
</span>
),
},
{
key: "referenceCount",
header: t("portal.sources.table.usedBy"),
@@ -99,20 +91,23 @@ export function SourcesTable({ sources, onRowClick }: SourcesTableProps) {
),
},
{
key: "open",
key: "expand",
header: "",
align: "right",
width: "2.5rem",
// The editor source has no page to open, so it shows no chevron.
render: (s) =>
s.type === EDITOR_SOURCE_TYPE ? null : (
<span className="portal-sources__caret" aria-hidden>
<ChevronRightRoundedIcon style={{ fontSize: "1.25rem" }} />
</span>
),
render: (s) => (
<span
className={
"portal-sources__caret" + (expandedId === s.id ? " is-open" : "")
}
aria-hidden
>
▸
</span>
),
},
],
[t],
[expandedId, t],
);
return (
@@ -122,8 +117,6 @@ export function SourcesTable({ sources, onRowClick }: SourcesTableProps) {
rows={sources}
rowKey={(s) => s.id}
onRowClick={onRowClick}
// The virtual editor row has no page to open, so it's inert - not a fake button.
isRowInteractive={(s) => s.type !== EDITOR_SOURCE_TYPE}
/>
);
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { Sparkline } from "@portal/components/sources/Sparkline";
function pointsOf(container: HTMLElement): string[] {
const poly = container.querySelector("polyline");
return (poly?.getAttribute("points") ?? "")
.trim()
.split(/\s+/)
.filter(Boolean);
}
describe("Sparkline", () => {
it("draws one point per value", () => {
const { container } = render(<Sparkline data={[1, 5, 2, 8, 3]} />);
expect(pointsOf(container)).toHaveLength(5);
});
it("renders nothing for an empty series", () => {
const { container } = render(<Sparkline data={[]} />);
expect(container.querySelector("svg")).toBeNull();
});
it("renders a flat finite line for an all-zero series (no divide-by-zero)", () => {
const { container } = render(<Sparkline data={[0, 0, 0, 0]} />);
const ys = pointsOf(container).map((p) => Number(p.split(",")[1]));
expect(ys).toHaveLength(4);
expect(ys.every(Number.isFinite)).toBe(true);
// All equal: a zero series is a single horizontal line, not NaN-laden.
expect(new Set(ys).size).toBe(1);
});
it("puts the peak value at the top of the band", () => {
const { container } = render(<Sparkline data={[0, 10]} height={36} />);
const ys = pointsOf(container).map((p) => Number(p.split(",")[1]));
// y grows downward in SVG, so the larger value (10) sits at the smaller y.
expect(ys[1]).toBeLessThan(ys[0]);
});
it("exposes its aria-label", () => {
const { container } = render(
<Sparkline data={[1, 2]} ariaLabel="Docs trend" />,
);
expect(container.querySelector("svg")?.getAttribute("aria-label")).toBe(
"Docs trend",
);
});
});
@@ -0,0 +1,53 @@
import "@portal/views/Sources.css";
interface SparklineProps {
/** Series values, oldest first. */
data: number[];
/** Drawing height in px; the width fills the container. */
height?: number;
ariaLabel?: string;
}
/**
* A tiny dependency-free trend line: normalises {@code data} to its own peak and draws a single
* polyline. The viewBox is fixed while the rendered width fills the container (the stroke stays
* crisp via non-scaling-stroke), so it adapts to any column without distorting the line weight.
*/
export function Sparkline({ data, height = 36, ariaLabel }: SparklineProps) {
if (data.length === 0) {
return null;
}
const width = 240;
const pad = 3;
const max = Math.max(...data, 1);
const stepX = data.length > 1 ? width / (data.length - 1) : 0;
const points = data
.map((value, i) => {
const x = i * stepX;
const y = pad + (1 - value / max) * (height - 2 * pad);
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
return (
<svg
className="portal-sparkline"
width="100%"
height={height}
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
role="img"
aria-label={ariaLabel}
>
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}
@@ -22,9 +22,6 @@ export interface SourceTypeMeta {
*/
export const EDITOR_SOURCE_TYPE = "editor";
/** The webhook source type. Its delivery URL + signing secret are minted server-side on create. */
export const WEBHOOK_SOURCE_TYPE = "webhook";
const SOURCE_TYPE_META: Record<string, SourceTypeMeta> = {
folder: {
labelKey: "portal.sources.types.folder.label",
@@ -41,11 +38,6 @@ const SOURCE_TYPE_META: Record<string, SourceTypeMeta> = {
icon: "☁",
accent: "brand",
},
webhook: {
labelKey: "portal.sources.types.webhook.label",
icon: "↯",
accent: "warning",
},
};
const UNKNOWN_TYPE_META: SourceTypeMeta = {
@@ -62,7 +54,7 @@ export function sourceTypeMeta(type: string): SourceTypeMeta {
export interface SourceFieldDef {
key: string;
labelKey: string;
control: "text" | "password" | "select" | "s3Connection";
control: "text" | "password" | "select";
required?: boolean;
placeholderKey?: string;
helperTextKey?: string;
@@ -156,11 +148,18 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [
descriptionKey: "portal.sources.types.s3.description",
fields: [
{
key: "connectionId",
labelKey: "portal.sources.types.s3.fields.connection.label",
control: "s3Connection",
key: "bucket",
labelKey: "portal.sources.types.s3.fields.bucket.label",
control: "text",
required: true,
helperTextKey: "portal.sources.types.s3.fields.connection.helperText",
placeholderKey: "portal.sources.types.s3.fields.bucket.placeholder",
},
{
key: "region",
labelKey: "portal.sources.types.s3.fields.region.label",
control: "text",
defaultValue: "us-east-1",
placeholderKey: "portal.sources.types.s3.fields.region.placeholder",
},
{
key: "prefix",
@@ -169,6 +168,25 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [
placeholderKey: "portal.sources.types.s3.fields.prefix.placeholder",
helperTextKey: "portal.sources.types.s3.fields.prefix.helperText",
},
{
key: "accessKeyId",
labelKey: "portal.sources.types.s3.fields.accessKeyId.label",
control: "text",
required: true,
},
{
key: "secretAccessKey",
labelKey: "portal.sources.types.s3.fields.secretAccessKey.label",
control: "password",
required: true,
},
{
key: "endpoint",
labelKey: "portal.sources.types.s3.fields.endpoint.label",
control: "text",
placeholderKey: "portal.sources.types.s3.fields.endpoint.placeholder",
helperTextKey: "portal.sources.types.s3.fields.endpoint.helperText",
},
{
key: "mode",
labelKey: "portal.sources.types.s3.fields.mode.label",
@@ -188,40 +206,6 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [
},
],
},
{
// URL + secret minted server-side (revealed once); an S3 connection makes staging durable, else local disk.
type: WEBHOOK_SOURCE_TYPE,
labelKey: "portal.sources.types.webhook.label",
descriptionKey: "portal.sources.types.webhook.description",
fields: [
{
key: "connectionId",
labelKey: "portal.sources.types.webhook.fields.connection.label",
control: "s3Connection",
helperTextKey:
"portal.sources.types.webhook.fields.connection.helperText",
},
{
key: "mode",
labelKey: "portal.sources.types.webhook.fields.mode.label",
control: "select",
defaultValue: "consume",
helperTextKey: "portal.sources.types.webhook.fields.mode.helperText",
options: [
{
value: "consume",
labelKey:
"portal.sources.types.webhook.fields.mode.options.consume",
},
{
value: "snapshot",
labelKey:
"portal.sources.types.webhook.fields.mode.options.snapshot",
},
],
},
],
},
];
/** Default option values for a type's create form. */
@@ -1,5 +1,4 @@
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { Avatar, Button } from "@app/ui";
import type { PendingInvitation } from "@portal/api/users";
import "@portal/views/Users.css";
@@ -12,15 +11,13 @@ interface PendingInvitationsProps {
/** Human "Expires in 3 days" from an ISO expiry; empty when absent, unparseable,
* or already past (the adapter filters expired invites, so no "expired" state). */
function expiryLabel(iso: string | undefined, t: TFunction): string {
function expiryLabel(iso: string | undefined, expiresWord: string): string {
if (!iso) return "";
const ts = Date.parse(iso);
if (!Number.isFinite(ts) || ts <= Date.now()) return "";
const days = Math.round((ts - Date.now()) / 86400000);
if (days === 0) return t("users.invites.expiresToday", "Expires today");
return t("users.invites.expiresInDays", "Expires in {{count}} days", {
count: days,
});
if (days === 0) return `${expiresWord} today`;
return `${expiresWord} in ${days === 1 ? "1 day" : `${days} days`}`;
}
/**
@@ -33,6 +30,7 @@ export function PendingInvitations({
onCancel,
}: PendingInvitationsProps) {
const { t } = useTranslation();
const expiresWord = t("users.invites.expires", "Expires");
return (
<section className="portal-users__group">
<header className="portal-users__group-head">
@@ -52,7 +50,7 @@ export function PendingInvitations({
</span>
</header>
{invitations.map((inv) => {
const expires = expiryLabel(inv.expiresAt, t);
const expires = expiryLabel(inv.expiresAt, expiresWord);
return (
<div className="portal-users__row" key={inv.id}>
<div className="portal-users__row-main">
@@ -10,20 +10,16 @@ import { usePlanTier } from "@portal/contexts/usePlanTier";
export type Tier = "free" | "pro" | "enterprise";
export interface TierInfo {
/** i18n key for the plan label; resolve with `t()` at the call site. */
labelKey: string;
label: string;
dotColor: string;
}
export const TIER_INFO: Record<Tier, TierInfo> = {
// Matches SaaS branding (editor/cloud Payg + PaygFree): the always-free
// manual-tools tier is "Editor plan"; the metered tier is "Processor plan".
free: { labelKey: "portal.tier.free", dotColor: "var(--color-text-4)" },
pro: { labelKey: "portal.tier.pro", dotColor: "var(--color-blue)" },
enterprise: {
labelKey: "portal.tier.enterprise",
dotColor: "var(--color-purple)",
},
free: { label: "Editor plan", dotColor: "var(--color-text-4)" },
pro: { label: "Processor plan", dotColor: "var(--color-blue)" },
enterprise: { label: "Enterprise plan", dotColor: "var(--color-purple)" },
};
interface TierContextValue {
@@ -55,18 +55,6 @@ function seedSources(): StoredSource[] {
enabled: false,
owner: "data-eng@acme.com",
},
{
id: "src-webhook",
name: "Partner uploads",
type: "webhook",
options: {
webhookId: "whk_demo_5f3a9c21b7",
signingSecret: "whsec_demo_2b8e1d47a9f60c35",
mode: "consume",
},
enabled: true,
owner: "you@acme.com",
},
];
}
@@ -88,7 +76,6 @@ const docCounts: Record<
"src-contracts": { total: 12840, last24h: 96, last30d: 2310 },
"src-archive": { total: 1180, last24h: 0, last30d: 0 },
"src-legacy": { total: 48600, last24h: 0, last30d: 0 },
"src-webhook": { total: 3120, last24h: 24, last30d: 640 },
};
function docsFor(id: string): {
@@ -109,23 +96,14 @@ function nextId(): string {
return `src_${Date.now().toString(36)}_${idCounter}`;
}
/** A demo token for a newly created webhook's server-generated id/secret (mock only). */
function randomToken(): string {
return (
Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)
);
}
function refsFor(id: string): SourcePolicyRef[] {
return references[id] ?? [];
}
/** Mirror the backend's secret redaction so config rows never surface a secret in the overview. */
function configRows(options: Record<string, unknown>) {
const secret = /secret|password|token/i;
return Object.entries(options).map(([key, value]) => ({
label: key.charAt(0).toUpperCase() + key.slice(1),
value: secret.test(key) ? "********" : String(value),
value: String(value),
}));
}
@@ -142,7 +120,6 @@ function toSourceView(
refs: SourcePolicyRef[],
): SourceView {
const docs = docsFor(source.id);
const webhookId = source.options.webhookId;
return {
id: source.id,
name: source.name,
@@ -154,10 +131,6 @@ function toSourceView(
docsTotal: docs.total,
docs24h: docs.last24h,
docs30d: docs.last30d,
webhookPath:
source.type === "webhook" && typeof webhookId === "string"
? `/api/v1/webhooks/${webhookId}`
: null,
};
}
@@ -234,18 +207,8 @@ export const sourcesHandlers = [
? store.find((s) => s.id === incoming.id)
: undefined;
const id = existing?.id ?? nextId();
// A new webhook's id + secret are minted server-side and revealed once on this create response.
let options = incoming.options ?? {};
if (incoming.type === "webhook" && !existing && !options.webhookId) {
options = {
...options,
webhookId: `whk_${randomToken()}`,
signingSecret: `whsec_${randomToken()}`,
};
}
const saved: StoredSource = {
...incoming,
options,
id,
owner: existing?.owner ?? "you@acme.com",
};
@@ -51,7 +51,7 @@ const ENTERPRISE_DEAL: Deal = {
const ENTERPRISE_LEDGER: LedgerGroup[] = [
{
stage: "trial",
label: "portal.procurement.journeySteps.trial.label",
label: "Trial",
docs: [
{
id: "doc-trial-quickstart",
@@ -71,7 +71,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [
},
{
stage: "quote",
label: "portal.procurement.journeySteps.quote.label",
label: "Quote",
docs: [
{
id: "doc-quote-formal",
@@ -84,7 +84,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [
},
{
stage: "security",
label: "portal.procurement.journeySteps.agreement.label",
label: "Agreement",
docs: [
{
id: "doc-agreement-enterprise",
@@ -97,7 +97,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [
},
{
stage: "procurement",
label: "portal.procurement.journeySteps.payment.label",
label: "Payment",
docs: [
{
id: "doc-pay-online",
@@ -124,7 +124,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [
},
{
stage: "active",
label: "portal.procurement.journeySteps.implementation.label",
label: "Implementation",
docs: [
{
id: "doc-active-playbook",
+3 -10
View File
@@ -93,11 +93,8 @@
width: 1.75rem;
height: 1.75rem;
border-radius: var(--radius-md);
flex-shrink: 0;
}
.portal-home__quick-icon svg {
width: 1.05rem;
height: 1.05rem;
font-size: 0.875rem;
font-weight: 600;
}
.portal-home__quick-text {
@@ -119,13 +116,9 @@
}
.portal-home__quick-arrow {
display: inline-flex;
font-size: 0.875rem;
color: var(--color-text-5);
}
.portal-home__quick-arrow svg {
width: 1rem;
height: 1rem;
}
.portal-home__quick-row:hover .portal-home__quick-arrow {
color: var(--color-blue);
+6 -36
View File
@@ -13,52 +13,31 @@ import "@portal/views/Home.css";
/* Quick actions card */
/* ──────────────────────────────────────────────────────────────────────── */
/** A stroke icon used inside the quick-action badge (replaces the old text
glyphs ⌃ ⇢ ⚙, which rendered off-style vs the portal's icon set). */
function QuickIcon({ d }: { d: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d={d} />
</svg>
);
}
/** Rows for the Quick Actions list. Each `view` navigates the portal. */
const QUICK_ACTIONS: Array<{
key: string;
iconD: string;
glyph: string;
bg: string;
fg: string;
view: ViewId;
}> = [
{
key: "buildPipeline",
iconD:
"M6 4a2 2 0 100 4 2 2 0 000-4zM18 16a2 2 0 100 4 2 2 0 000-4zM6 8v6a4 4 0 004 4h4",
glyph: "⌃",
bg: "var(--color-purple-light)",
fg: "var(--color-purple)",
view: "pipelines",
},
{
key: "connectSource",
iconD:
"M10 13a5 5 0 007 0l3-3a5 5 0 00-7-7l-1 1M14 11a5 5 0 00-7 0l-3 3a5 5 0 007 7l1-1",
glyph: "⇢",
bg: "var(--color-green-light)",
fg: "var(--color-green-dark)",
view: "sources",
},
{
key: "issueApiKey",
iconD:
"M2.6 17.4A2 2 0 002 18.8V21a1 1 0 001 1h3a1 1 0 001-1v-1a1 1 0 011-1h1a1 1 0 001-1v-1a1 1 0 011-1h.2a2 2 0 001.4-.6l.8-.8a6.5 6.5 0 10-4-4z M16.5 7.5 h.01",
glyph: "⚙",
bg: "var(--color-amber-light)",
fg: "var(--color-amber-dark)",
view: "infrastructure",
@@ -96,21 +75,12 @@ function QuickActions() {
style={{ background: action.bg, color: action.fg }}
aria-hidden
>
<QuickIcon d={action.iconD} />
{action.glyph}
</span>
}
rightSection={
<span className="portal-home__quick-arrow" aria-hidden>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 6l6 6-6 6" />
</svg>
→
</span>
}
>

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