Merge remote-tracking branch 'origin/main' into multi-api-keys-team-scope

# Conflicts:
#	frontend/editor/src/portal/components/sources/sourceTypes.ts
This commit is contained in:
Anthony Stirling
2026-07-10 14:14:27 +01:00
107 changed files with 4325 additions and 5331 deletions
@@ -246,6 +246,14 @@ public class ApplicationProperties {
* and paused runs are kept regardless of age.
*/
private int runExpiryMinutes = 30;
/**
* Whether a policy S3 source's custom endpoint may resolve to a loopback, link-local, or
* private address. Off by default so a user-supplied endpoint cannot be pointed at internal
* services (e.g. the cloud metadata address); enable for a self-hosted MinIO or other
* in-network object store.
*/
private boolean allowPrivateS3Endpoints = false;
}
@Data
@@ -133,29 +133,45 @@ public final class S3Clients {
* storage.s3.allow-private-endpoints=true}.
*/
static void validateEndpointHost(URI endpoint, boolean allowPrivate) {
validateEndpointHost(
endpoint,
allowPrivate,
"storage.s3.endpoint",
"set storage.s3.allow-private-endpoints=true to opt in"
+ " (e.g. for MinIO or in-cluster S3).");
}
/**
* The same private-address guard for S3 endpoints configured outside the {@code storage.s3.*}
* block (e.g. per-source policy config), with the setting named in messages supplied by the
* caller.
*/
public static void validateEndpointHost(
URI endpoint, boolean allowPrivate, String settingName, String optInHint) {
if (allowPrivate) {
return;
}
String host = endpoint.getHost();
if (host == null || host.isBlank()) {
throw new IllegalStateException("storage.s3.endpoint must include a host: " + endpoint);
throw new IllegalStateException(settingName + " must include a host: " + endpoint);
}
InetAddress[] addresses;
try {
addresses = InetAddress.getAllByName(host);
} catch (UnknownHostException e) {
throw new IllegalStateException(
"Unable to resolve storage.s3.endpoint host '" + host + "'", e);
"Unable to resolve " + settingName + " host '" + host + "'", e);
}
for (InetAddress address : addresses) {
if (isPrivateOrLocal(address)) {
throw new IllegalStateException(
"storage.s3.endpoint host '"
settingName
+ " host '"
+ host
+ "' resolves to private/link-local address "
+ address.getHostAddress()
+ "; set storage.s3.allow-private-endpoints=true to opt in"
+ " (e.g. for MinIO or in-cluster S3).");
+ "; "
+ optInHint);
}
}
}
@@ -0,0 +1,31 @@
package stirling.software.proprietary.integration.crypto;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
/**
* {@link EncryptedStringConverter} for columns that held plaintext before encryption shipped:
* writes are always encrypted, but a stored value that is not valid ciphertext is returned as-is,
* so pre-encryption rows keep loading and become encrypted on their next save. The discrimination
* is exact for JSON payloads, which can never be mistaken for ciphertext ('{' is not in the Base64
* alphabet). The trade-off is that a genuinely corrupted ciphertext surfaces as garbage to the
* caller's parser instead of failing here.
*/
@Converter
public class LenientEncryptedStringConverter implements AttributeConverter<String, String> {
@Override
public String convertToDatabaseColumn(String attribute) {
return CredentialEncryption.encrypt(attribute);
}
@Override
public String convertToEntityAttribute(String dbData) {
try {
return CredentialEncryption.decrypt(dbData);
} catch (IllegalArgumentException | IllegalStateException e) {
// Not ciphertext: legacy plaintext from before encryption shipped.
return dbData;
}
}
}
@@ -48,7 +48,9 @@ import stirling.software.proprietary.policy.engine.PolicyRunHandle;
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.engine.PolicyValidator;
import stirling.software.proprietary.policy.engine.SweepOutcome;
import stirling.software.proprietary.policy.ledger.ProcessedLedger;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyInputs;
@@ -64,6 +66,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
import stirling.software.proprietary.policy.trigger.TriggerInfo;
import stirling.software.proprietary.util.SecretMasker;
/**
* Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code
@@ -202,7 +205,7 @@ public class PolicyController {
+ " assigned; returns the stored policy with its id.")
public ResponseEntity<Policy> savePolicy(@RequestBody Policy policy) {
requirePolicyEditingAllowed();
Policy owned = resolveOwnership(policy);
Policy owned = withStoredOutputSecrets(resolveOwnership(policy));
requireAccessibleSources(owned);
try {
policyValidator.validate(owned);
@@ -213,7 +216,7 @@ public class PolicyController {
// Re-sync trigger registrations now so a new/changed folder-watch policy starts being
// watched immediately instead of after the next reconcile sweep.
policyTriggerManager.notifyPoliciesChanged();
return ResponseEntity.ok(saved);
return ResponseEntity.ok(withMaskedOutputSecrets(saved));
}
@PutMapping("/order")
@@ -282,6 +285,50 @@ public class PolicyController {
teamId);
}
/** Output secrets never leave the server: reads return the redaction sentinel instead. */
private static Policy withMaskedOutputSecrets(Policy policy) {
return withOutput(
policy,
new OutputSpec(
policy.output().type(), SecretMasker.mask(policy.output().options())));
}
/**
* An edit that round-trips a masked read sends output secrets back as the sentinel; restore
* them from the stored policy so saving without re-typing keeps them (validation then runs
* against the real values).
*/
private Policy withStoredOutputSecrets(Policy incoming) {
if (incoming.id() == null || incoming.id().isBlank()) {
return incoming;
}
return policyStore
.get(incoming.id())
.map(
existing ->
withOutput(
incoming,
new OutputSpec(
incoming.output().type(),
SecretMasker.restoreRedacted(
incoming.output().options(),
existing.output().options()))))
.orElse(incoming);
}
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());
}
/**
* Creating, editing, pausing/resuming, and deleting policies requires the editor role for the
* caller's team — a team leader on SaaS (see {@link PolicyManagementAuthority}); the global
@@ -306,9 +353,14 @@ public class PolicyController {
@GetMapping
@Operation(
summary = "List policies",
description = "Lists the policies belonging to the caller's team.")
description =
"Lists the policies belonging to the caller's team. Secret-bearing output"
+ " options are returned as a redaction sentinel, never their stored"
+ " values.")
public List<Policy> listPolicies() {
return policyAccessGuard.visibleFrom(policyStore);
return policyAccessGuard.visibleFrom(policyStore).stream()
.map(PolicyController::withMaskedOutputSecrets)
.toList();
}
@GetMapping("/overview")
@@ -337,11 +389,17 @@ public class PolicyController {
}
@GetMapping("/{policyId}")
@Operation(summary = "Get a policy by id")
@Operation(
summary = "Get a policy by id",
description =
"Secret-bearing output options are returned as a redaction sentinel, never"
+ " their stored values; an edit that sends the sentinel back keeps"
+ " them.")
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
return policyStore
.get(policyId)
.filter(policyAccessGuard::canAccess)
.map(PolicyController::withMaskedOutputSecrets)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@@ -412,9 +470,10 @@ public class PolicyController {
description =
"Pulls the policy's configured sources and runs the pipeline now, regardless of"
+ " the enabled flag (which only gates automatic triggering). Returns"
+ " the ids of the runs started; poll the run-status endpoint for each."
+ " Empty when the sources yielded no work to do.")
public ResponseEntity<List<String>> trigger(@PathVariable String policyId) {
+ " the ids of the runs started (poll the run-status endpoint for each)"
+ " plus what the sweep skipped - already-processed, parked-by-failure,"
+ " and in-flight counts - so an empty result explains itself.")
public ResponseEntity<SweepOutcome> trigger(@PathVariable String policyId) {
Policy policy =
policyStore
.get(policyId)
@@ -44,7 +44,7 @@ public class PolicyRunner {
private final ProcessedLedger processedLedger;
/** Full-listing sweep: resolve every source, then reconcile the ledger. */
public List<String> run(Policy policy) {
public SweepOutcome run(Policy policy) {
return run(policy, SweepKind.FULL);
}
@@ -52,10 +52,10 @@ public class PolicyRunner {
* Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so
* one failure does not affect the others. No sources means one run with no input (generator
* pipeline). Missing or disabled sources are skipped so one broken reference does not stop the
* rest. Returns the ids of the runs it started (empty when sources yielded no work), so a
* manual trigger can report back which runs to follow.
* rest. Returns the ids of the runs it started plus what the sweep skipped, so a manual trigger
* can report which runs to follow or why nothing ran.
*/
public List<String> run(Policy policy, SweepKind sweep) {
public SweepOutcome run(Policy policy, SweepKind sweep) {
long sweepStart = System.currentTimeMillis();
PolicySweep context = new PolicySweep(policy.id(), sweep, processedLedger);
List<String> runIds = new ArrayList<>();
@@ -95,7 +95,7 @@ public class PolicyRunner {
policy.id());
}
}
return runIds;
return context.outcome(runIds);
}
/** Run a stored policy on caller-supplied files (e.g. manual upload), bypassing its sources. */
@@ -86,4 +86,32 @@ final class PolicySweep implements ResolveContext {
synchronized Set<String> presentIdentities() {
return Set.copyOf(present);
}
/**
* Summarise the sweep from state already in hand (no extra ledger reads): the prefetched rows
* were loaded before claiming, and successful claims flipped their entries to PROCESSING, so
* what remains DONE or ERROR is exactly what this sweep skipped.
*/
synchronized SweepOutcome outcome(List<String> runIds) {
int alreadyProcessed = 0;
int parked = 0;
int processing = 0;
for (String identity : present) {
ClaimState state = prefetched.get(identity);
if (state == null) {
continue;
}
switch (state.status()) {
case DONE -> alreadyProcessed++;
case ERROR -> parked++;
case PROCESSING, INTERRUPTED -> processing++;
}
}
return new SweepOutcome(
runIds,
present.size(),
alreadyProcessed,
parked,
Math.max(0, processing - runIds.size()));
}
}
@@ -0,0 +1,19 @@
package stirling.software.proprietary.policy.engine;
import java.util.List;
/**
* What one policy sweep found and started, so a manual trigger can explain an empty result instead
* of a blanket "nothing to do": how many files the sources listed, how many were skipped because
* they are already processed at their current version, how many are parked by a failed run (not
* retried until they change or history is cleared), and how many are still in flight from an
* earlier sweep. Counts are zero for {@link SweepKind#LIGHT} sweeps, which do not take a full
* listing.
*/
public record SweepOutcome(
List<String> runIds, int filesListed, int alreadyProcessed, int parked, int inFlight) {
public SweepOutcome {
runIds = runIds == null ? List.of() : List.copyOf(runIds);
}
}
@@ -0,0 +1,286 @@
package stirling.software.proprietary.policy.input;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.AbstractResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.S3Identities;
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
import software.amazon.awssdk.services.s3.model.S3Exception;
import software.amazon.awssdk.services.s3.model.S3Object;
/**
* Reads input files from an Amazon S3 (or S3-compatible) bucket; each listed object is its own unit
* of work, claimed through the {@link ResolveContext} ledger and tracked in place. Identity and
* version gate come from {@link S3Identities}, so the steady-state sweep never downloads content.
* Options (see {@link S3Config}): "bucket" (required), "region" (default us-east-1), "prefix" (only
* keys starting with it are read), "endpoint" (S3-compatible stores such as MinIO; path-style
* addressing is used automatically), "accessKeyId" and "secretAccessKey" (required; requests are
* never signed with the server's own AWS identity), and "mode" which is "consume" (default: a
* processed object is deleted once every policy that claimed it has settled successfully and it is
* still the version that ran; failures stay in place and are not retried until they change) or
* "snapshot" (stateless, every run sees the full set). Keys ending in "/" (folder placeholders) and
* keys with a dot-prefixed path segment are never picked up, mirroring the folder source's
* hidden-file rule.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3InputSource implements InputSource {
private static final String TYPE = "s3";
private final S3ConnectionPool connectionPool;
@Override
public String type() {
return TYPE;
}
@Override
public boolean supports(InputSpec spec) {
return spec != null && TYPE.equals(spec.type());
}
/**
* 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 = S3Config.from(spec.options());
try {
connectionPool.clientFor(config).listObjectsV2(listRequest(config).maxKeys(1).build());
} catch (SdkException e) {
throw new IllegalArgumentException(
"cannot access s3://"
+ config.bucket()
+ "/"
+ config.prefix()
+ ": "
+ e.getMessage(),
e);
}
}
@Override
public List<ResolvedInput> resolve(InputSpec spec, ResolveContext ctx) throws IOException {
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".
List<S3Object> objects = listObjects(client, config);
if (config.snapshot()) {
return objects.stream()
.map(
object ->
ResolvedInput.of(
PolicyInputs.of(
List.of(
objectResource(
client, config, object)))))
.toList();
}
ctx.reportPresent(
objects.stream()
.map(object -> S3Identities.identity(config.bucket(), object.key()))
.toList());
List<ResolvedInput> work = new ArrayList<>();
for (S3Object object : objects) {
String identity = S3Identities.identity(config.bucket(), object.key());
String gate = S3Identities.gate(object.eTag(), object.size(), object.lastModified());
if (!ctx.claim(identity, gate, null)) {
continue;
}
work.add(
new ResolvedInput(
PolicyInputs.of(List.of(objectResource(client, config, object))),
success ->
completeConsumed(
ctx,
client,
config,
object.key(),
identity,
gate,
success)));
}
return work;
}
/**
* Settle at the version this run claimed, then remove the object only when it still carries
* that version and every policy that claimed it has settled DONE, mirroring the folder source's
* consensus delete. A failed run settles ERROR and never deletes; the DONE row of an object
* that could not be deleted still stops reprocessing.
*/
private void completeConsumed(
ResolveContext ctx,
S3Client client,
S3Config config,
String key,
String identity,
String claimGate,
boolean success) {
ctx.settle(identity, claimGate, null, success);
if (!success) {
return;
}
try {
HeadObjectResponse head =
client.headObject(
HeadObjectRequest.builder().bucket(config.bucket()).key(key).build());
String currentGate =
S3Identities.gate(head.eTag(), head.contentLength(), head.lastModified());
if (currentGate.equals(claimGate) && ctx.allSettledDone(identity)) {
client.deleteObject(
DeleteObjectRequest.builder().bucket(config.bucket()).key(key).build());
}
} catch (NoSuchKeyException alreadyGone) {
// Removed by the user or a co-watching policy's own consensus delete: nothing to do.
} catch (S3Exception e) {
if (e.statusCode() == 404) {
return;
}
log.warn("Could not remove consumed S3 object {}: {}", identity, e.getMessage());
} catch (SdkException e) {
log.warn("Could not remove consumed S3 object {}: {}", identity, e.getMessage());
}
}
/** Every ingestible object under the configured prefix, across all listing pages. */
private static List<S3Object> listObjects(S3Client client, S3Config config) {
List<S3Object> objects = new ArrayList<>();
String continuationToken = null;
do {
ListObjectsV2Request.Builder request = listRequest(config);
if (continuationToken != null) {
request.continuationToken(continuationToken);
}
ListObjectsV2Response page = client.listObjectsV2(request.build());
for (S3Object object : page.contents()) {
if (ingestible(object)) {
objects.add(object);
}
}
continuationToken = page.nextContinuationToken();
} while (continuationToken != null);
return objects;
}
private static ListObjectsV2Request.Builder listRequest(S3Config config) {
ListObjectsV2Request.Builder request =
ListObjectsV2Request.builder().bucket(config.bucket());
if (!config.prefix().isEmpty()) {
request.prefix(config.prefix());
}
return request;
}
/**
* Folder-placeholder keys (ending "/") and keys with a dot-prefixed segment are skipped, so a
* hidden convention (e.g. a future output sink's staging prefix) is never re-ingested.
*/
private static boolean ingestible(S3Object object) {
String key = object.key();
if (key.isEmpty() || key.endsWith("/")) {
return false;
}
for (String segment : key.split("/")) {
if (segment.startsWith(".")) {
return false;
}
}
return true;
}
private static Resource objectResource(S3Client client, S3Config config, S3Object object) {
return new S3ObjectResource(client, config.bucket(), object);
}
/**
* Streams the object on demand, pinned to the ETag observed at listing time so a run never
* reads a different version than the sweep claimed (a swapped object fails the read with a
* precondition error and the new version is claimed by a later sweep).
*/
private static final class S3ObjectResource extends AbstractResource {
private final S3Client client;
private final String bucket;
private final String key;
private final String eTag;
private final Long size;
private S3ObjectResource(S3Client client, String bucket, S3Object object) {
this.client = client;
this.bucket = bucket;
this.key = object.key();
this.eTag = object.eTag();
this.size = object.size();
}
@Override
public InputStream getInputStream() throws IOException {
GetObjectRequest.Builder request = GetObjectRequest.builder().bucket(bucket).key(key);
if (eTag != null && !eTag.isBlank()) {
request.ifMatch(eTag);
}
try {
return client.getObject(request.build());
} catch (NoSuchKeyException e) {
throw new FileNotFoundException(getDescription() + " no longer exists");
} catch (SdkException e) {
throw new IOException(
"Could not read " + getDescription() + ": " + e.getMessage(), e);
}
}
/** Listed just now; readers get a precise error from {@link #getInputStream} instead. */
@Override
public boolean exists() {
return true;
}
@Override
public long contentLength() {
return size == null ? -1 : size;
}
@Override
public String getFilename() {
return key.substring(key.lastIndexOf('/') + 1);
}
@Override
public String getDescription() {
return "S3 object " + S3Identities.identity(bucket, key);
}
}
}
@@ -15,7 +15,6 @@ import java.util.List;
import java.util.UUID;
import java.util.stream.Stream;
import org.apache.commons.io.FilenameUtils;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
@@ -82,7 +81,7 @@ public class FolderOutputSink implements PolicyOutputSink {
List<ResultFile> results = new ArrayList<>();
for (int i = 0; i < outputs.size(); i++) {
Resource resource = outputs.get(i);
String name = safeName(resource.getFilename(), i);
String name = OutputNames.safeName(resource.getFilename(), i);
Path staged = tmpDir.resolve(UUID.randomUUID().toString());
String contentHash = stage(resource, staged, delivery.policyId() != null);
long size = Files.size(staged);
@@ -198,29 +197,14 @@ public class FolderOutputSink implements PolicyOutputSink {
return Path.of(directory.toString());
}
// Strip any directory component / "../" so a crafted output name cannot escape targetDir.
private static String safeName(String filename, int index) {
if (filename == null || filename.isBlank()) {
return "output-" + index;
}
String name = FilenameUtils.getName(filename);
if (name.isBlank() || ".".equals(name) || "..".equals(name)) {
return "output-" + index;
}
return name;
}
// Non-colliding path, appending " (n)" before the extension.
private static Path uniqueTarget(Path dir, String filename) {
Path candidate = dir.resolve(filename);
if (!Files.exists(candidate)) {
return candidate;
}
String base = FilenameUtils.getBaseName(filename);
String ext = FilenameUtils.getExtension(filename);
String suffix = ext.isEmpty() ? "" : "." + ext;
for (int n = 1; ; n++) {
Path next = dir.resolve(base + " (" + n + ")" + suffix);
Path next = dir.resolve(OutputNames.numbered(filename, n));
if (!Files.exists(next)) {
return next;
}
@@ -0,0 +1,29 @@
package stirling.software.proprietary.policy.output;
import org.apache.commons.io.FilenameUtils;
/** Output file naming shared by the sinks: sanitised base names and collision suffixes. */
final class OutputNames {
private OutputNames() {}
/** Strip any directory component / "../" so a crafted output name cannot escape the target. */
static String safeName(String filename, int index) {
if (filename == null || filename.isBlank()) {
return "output-" + index;
}
String name = FilenameUtils.getName(filename);
if (name.isBlank() || ".".equals(name) || "..".equals(name)) {
return "output-" + index;
}
return name;
}
/** The nth alternative for a taken name, appending " (n)" before the extension. */
static String numbered(String filename, int n) {
String base = FilenameUtils.getBaseName(filename);
String ext = FilenameUtils.getExtension(filename);
String suffix = ext.isEmpty() ? "" : "." + ext;
return base + " (" + n + ")" + suffix;
}
}
@@ -0,0 +1,270 @@
package stirling.software.proprietary.policy.output;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.ResultFile;
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.S3Identities;
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.HeadObjectRequest;
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
import software.amazon.awssdk.services.s3.model.S3Exception;
/**
* Uploads a run's outputs to the bucket and key prefix given in the {@link OutputSpec} (same
* connection options as the S3 input source; "prefix" is the destination folder). The
* record-before-visible obligation is met without a rename step: a single-part PUT's ETag is the
* MD5 of its content on plain and SSE-S3 buckets, so the ledger row is recorded at that predicted
* gate BEFORE the upload, and the object is claimed under exactly the gate the next listing
* returns. Stores where the returned ETag differs (e.g. SSE-KMS) are re-recorded at the actual gate
* immediately after the PUT - a narrow race those buckets accept rather than a broken loop. Names
* never overwrite: uploads are conditional on the key not existing ({@code If-None-Match: *}),
* re-picking "name (n).ext" on collision exactly like the folder sink; stores without
* conditional-write support fall back to an existence check per candidate.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3OutputSink implements PolicyOutputSink {
private static final String TYPE = "s3";
private final S3ConnectionPool connectionPool;
private final ProcessedLedger processedLedger;
@Override
public String type() {
return TYPE;
}
@Override
public boolean supports(OutputSpec spec) {
return spec != null && TYPE.equals(spec.type());
}
/**
* 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(S3Config.from(spec.options()));
}
@Override
public List<ResultFile> deliver(
OutputDelivery delivery, List<Resource> outputs, OutputSpec spec) throws IOException {
S3Config config = S3Config.from(spec.options());
S3Client client = connectionPool.clientFor(config);
List<ResultFile> results = new ArrayList<>();
for (int i = 0; i < outputs.size(); i++) {
Resource resource = outputs.get(i);
String name = OutputNames.safeName(resource.getFilename(), i);
Path staged = Files.createTempFile("s3-output-", ".tmp");
try {
String predictedGate = stage(resource, staged, delivery.policyId() != null);
long size = Files.size(staged);
String key = upload(delivery, client, config, name, staged, predictedGate);
String contentType =
MediaTypeFactory.getMediaType(name)
.orElse(MediaType.APPLICATION_OCTET_STREAM)
.toString();
results.add(
ResultFile.builder()
.fileId(UUID.randomUUID().toString())
.fileName(S3Identities.identity(config.bucket(), key))
.contentType(contentType)
.fileSize(size)
.build());
log.debug(
"Wrote policy run {} output to {}",
delivery.runId(),
S3Identities.identity(config.bucket(), key));
} finally {
try {
Files.deleteIfExists(staged);
} catch (IOException e) {
log.warn("Could not remove S3 staging file {}: {}", staged, e.getMessage());
}
}
}
return results;
}
/**
* Spool the output to a local staging file (S3 needs a known content length, and the body must
* be re-readable across collision retries). For a recorded delivery the MD5 - the predicted
* single-part ETag - is digested in the same pass; ad-hoc runs record nothing and skip it.
*/
private static String stage(Resource resource, Path staged, boolean recorded)
throws IOException {
if (!recorded) {
try (InputStream is = resource.getInputStream();
OutputStream out = Files.newOutputStream(staged)) {
is.transferTo(out);
}
return null;
}
MessageDigest digest = newMd5();
try (InputStream is = resource.getInputStream();
DigestOutputStream out =
new DigestOutputStream(Files.newOutputStream(staged), digest)) {
is.transferTo(out);
}
return HexFormat.of().formatHex(digest.digest());
}
/**
* The S3 shape of the folder sink's record-then-rename loop. The ledger row must exist before
* the object is visible, so it is recorded at the predicted gate before the PUT; losing the
* chosen key to a concurrent writer (the conditional PUT fails) forgets the just-recorded row -
* whatever object actually owns that key must stay claimable at any version - then re-picks. A
* PUT that never made the object visible also forgets its row.
*/
private String upload(
OutputDelivery delivery,
S3Client client,
S3Config config,
String name,
Path staged,
String predictedGate)
throws IOException {
String keyPrefix = keyPrefix(config);
boolean conditionalPuts = true;
for (int attempt = 0; ; attempt++) {
String key = keyPrefix + (attempt == 0 ? name : OutputNames.numbered(name, attempt));
String identity = S3Identities.identity(config.bucket(), key);
if (!conditionalPuts && exists(client, config.bucket(), key)) {
continue;
}
if (delivery.policyId() != null) {
processedLedger.recordOutput(delivery.policyId(), identity, predictedGate, null);
}
PutObjectRequest.Builder put =
PutObjectRequest.builder().bucket(config.bucket()).key(key);
if (conditionalPuts) {
put.ifNoneMatch("*");
}
try {
PutObjectResponse response =
client.putObject(put.build(), RequestBody.fromFile(staged));
reRecordIfGateDiffers(delivery, identity, predictedGate, response);
return key;
} catch (S3Exception e) {
forgetRecorded(delivery, identity, predictedGate);
if (conditionalPuts && e.statusCode() == 412) {
// Known edge: if our own PUT succeeded server-side but the response was lost
// and the SDK retried, that retry 412s here too - we then upload under the
// next name, leaving the first object row-less (claimable, single duplicate).
// Requires a response-lost network flake at exactly this moment; accepted.
log.debug("Output key {} taken concurrently; re-picking", identity);
continue;
}
if (conditionalPuts && e.statusCode() == 501) {
// Store without conditional-write support: retry this candidate with a plain
// existence check instead.
log.debug(
"Conditional PUT unsupported by {}; falling back to existence checks",
config.bucket());
conditionalPuts = false;
attempt--;
continue;
}
throw new IOException("Could not upload " + identity + ": " + e.getMessage(), e);
} catch (SdkException e) {
forgetRecorded(delivery, identity, predictedGate);
throw new IOException("Could not upload " + identity + ": " + e.getMessage(), e);
}
}
}
/**
* On buckets where a PUT's ETag is not the content MD5 (e.g. SSE-KMS), re-record at the gate
* listings will actually return. The row is briefly at the wrong gate while the object is
* already visible - the narrow race such stores trade for a working self-output skip.
*/
private void reRecordIfGateDiffers(
OutputDelivery delivery,
String identity,
String predictedGate,
PutObjectResponse response) {
if (delivery.policyId() == null) {
return;
}
String actualGate = S3Identities.gate(response.eTag(), null, null);
if (!actualGate.equals(predictedGate)) {
log.debug(
"PUT ETag for {} differs from content MD5 (encrypted bucket?); re-recording",
identity);
processedLedger.recordOutput(delivery.policyId(), identity, actualGate, null);
}
}
private void forgetRecorded(OutputDelivery delivery, String identity, String predictedGate) {
if (delivery.policyId() != null) {
processedLedger.forgetOutput(delivery.policyId(), identity, predictedGate);
}
}
private static boolean exists(S3Client client, String bucket, String key) {
try {
client.headObject(HeadObjectRequest.builder().bucket(bucket).key(key).build());
return true;
} catch (NoSuchKeyException e) {
return false;
} catch (S3Exception e) {
if (e.statusCode() == 404) {
return false;
}
throw e;
}
}
/** The configured prefix as a key-path prefix: "processed" and "processed/" mean the same. */
private static String keyPrefix(S3Config config) {
String prefix = config.prefix();
if (prefix.isEmpty() || prefix.endsWith("/")) {
return prefix;
}
return prefix + "/";
}
private static MessageDigest newMd5() {
try {
return MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("MD5 unavailable", e);
}
}
}
@@ -0,0 +1,103 @@
package stirling.software.proprietary.policy.s3;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Map;
/**
* Connection settings shared by the S3 input source and output sink, parsed from a spec's options
* map. Credentials are required: there is deliberately no fallback to the server's own AWS
* credential chain, so user-supplied config can never borrow the host's identity. {@code snapshot}
* is input-only and ignored by the sink.
*/
public record S3Config(
String bucket,
String region,
String prefix,
String endpoint,
String accessKeyId,
String secretAccessKey,
boolean snapshot) {
private static final String BUCKET_OPTION = "bucket";
private static final String REGION_OPTION = "region";
private static final String PREFIX_OPTION = "prefix";
private static final String ENDPOINT_OPTION = "endpoint";
private static final String ACCESS_KEY_ID_OPTION = "accessKeyId";
private static final String SECRET_ACCESS_KEY_OPTION = "secretAccessKey";
private static final String MODE_OPTION = "mode";
private static final String MODE_CONSUME = "consume";
private static final String MODE_SNAPSHOT = "snapshot";
public static S3Config from(Map<String, Object> options) {
String bucket = trimmed(options.get(BUCKET_OPTION));
if (bucket == null) {
throw new IllegalArgumentException("s3 config requires a 'bucket' option");
}
String region = trimmed(options.get(REGION_OPTION));
String prefix = trimmed(options.get(PREFIX_OPTION));
if (prefix != null && prefix.startsWith("/")) {
prefix = prefix.substring(1);
}
String endpoint = validEndpoint(trimmed(options.get(ENDPOINT_OPTION)));
String accessKeyId = trimmed(options.get(ACCESS_KEY_ID_OPTION));
String secretAccessKey = trimmed(options.get(SECRET_ACCESS_KEY_OPTION));
if (accessKeyId == null || secretAccessKey == null) {
throw new IllegalArgumentException(
"s3 config requires an 'accessKeyId' and 'secretAccessKey'");
}
String mode = trimmed(options.get(MODE_OPTION));
if (mode != null && !MODE_CONSUME.equals(mode) && !MODE_SNAPSHOT.equals(mode)) {
throw new IllegalArgumentException("s3 config 'mode' must be 'consume' or 'snapshot'");
}
return new S3Config(
bucket,
region == null ? "us-east-1" : region,
prefix == null ? "" : prefix,
endpoint,
accessKeyId,
secretAccessKey,
MODE_SNAPSHOT.equals(mode));
}
private static String validEndpoint(String endpoint) {
if (endpoint == null) {
return null;
}
URI uri;
try {
uri = new URI(endpoint);
} catch (URISyntaxException e) {
throw new IllegalArgumentException("s3 config 'endpoint' is not a valid URL", e);
}
if (!"http".equals(uri.getScheme()) && !"https".equals(uri.getScheme())) {
throw new IllegalArgumentException(
"s3 config 'endpoint' must be an http(s) URL, e.g. https://s3.example.com");
}
return endpoint;
}
private static String trimmed(Object value) {
if (value == null) {
return null;
}
String text = value.toString().trim();
return text.isEmpty() ? null : text;
}
/** Never prints the credentials, so an accidental log line cannot leak them. */
@Override
public String toString() {
return "S3Config[bucket="
+ bucket
+ ", region="
+ region
+ ", prefix="
+ prefix
+ ", endpoint="
+ endpoint
+ ", snapshot="
+ snapshot
+ "]";
}
}
@@ -0,0 +1,110 @@
package stirling.software.proprietary.policy.s3;
import java.net.URI;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import jakarta.annotation.PreDestroy;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.cluster.s3.S3Clients;
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.S3ClientBuilder;
import software.amazon.awssdk.services.s3.S3Configuration;
/**
* Long-lived {@link S3Client}s for policy S3 sources and sinks, one per distinct {@link S3Config},
* closed at shutdown. An edited spec simply maps to a new entry, and a stale entry costs nothing
* (the URL-connection HTTP client holds no pooled sockets or threads). Clients sign exclusively
* with the spec's own credentials - there is deliberately no fallback to the server's AWS
* credential chain, so user-supplied config can never borrow the host's identity. Endpoints are
* guarded against private addresses before a client is ever built, since they come from portal
* users rather than the operator.
*/
@Service
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3ConnectionPool {
private final ApplicationProperties applicationProperties;
private final Function<S3Config, S3Client> clientFactory;
private final Map<S3Config, S3Client> clients = new ConcurrentHashMap<>();
@Autowired
public S3ConnectionPool(ApplicationProperties applicationProperties) {
this(applicationProperties, S3ConnectionPool::buildClient);
}
/** Factory-injecting constructor for tests. */
public S3ConnectionPool(
ApplicationProperties applicationProperties,
Function<S3Config, S3Client> clientFactory) {
this.applicationProperties = applicationProperties;
this.clientFactory = clientFactory;
}
public S3Client clientFor(S3Config config) {
return clients.computeIfAbsent(
config,
c -> {
requirePermittedEndpoint(c);
return clientFactory.apply(c);
});
}
/**
* A user-supplied endpoint must not reach loopback, link-local, or private addresses unless the
* operator has opted in via {@code policies.allowPrivateS3Endpoints}.
*/
private void requirePermittedEndpoint(S3Config config) {
if (config.endpoint() == null) {
return;
}
try {
S3Clients.validateEndpointHost(
URI.create(config.endpoint()),
applicationProperties.getPolicies().isAllowPrivateS3Endpoints(),
"S3 source endpoint",
"set policies.allowPrivateS3Endpoints=true to opt in (e.g. for a local"
+ " MinIO).");
} catch (IllegalStateException e) {
throw new IllegalArgumentException(e.getMessage(), e);
}
}
private static S3Client buildClient(S3Config config) {
S3ClientBuilder builder =
S3Client.builder()
.httpClient(UrlConnectionHttpClient.create())
.region(Region.of(config.region()))
// Path-style addressing whenever a custom endpoint is set: S3-compatible
// stores rarely support virtual-hosted bucket DNS.
.serviceConfiguration(
S3Configuration.builder()
.pathStyleAccessEnabled(config.endpoint() != null)
.build())
.credentialsProvider(
StaticCredentialsProvider.create(
AwsBasicCredentials.create(
config.accessKeyId(), config.secretAccessKey())));
if (config.endpoint() != null) {
builder.endpointOverride(URI.create(config.endpoint()));
}
return builder.build();
}
@PreDestroy
void closeClients() {
clients.values().forEach(S3Client::close);
clients.clear();
}
}
@@ -0,0 +1,28 @@
package stirling.software.proprietary.policy.s3;
import java.time.Instant;
/**
* The S3 backend's identity and version scheme, shared by {@code S3InputSource} and {@code
* S3OutputSink} so outputs are recorded under exactly the identity and gate the next listing
* derives. Identity is {@code s3://bucket/key}; the gate is the ETag every listing returns for free
* (multipart ETags are not content hashes, so any ETag change simply reads as a new version).
*/
public final class S3Identities {
private S3Identities() {}
public static String identity(String bucket, String key) {
return "s3://" + bucket + "/" + key;
}
/** ETag stripped of its quotes; falls back to size:lastModified for stores that omit it. */
public static String gate(String eTag, Long size, Instant lastModified) {
if (eTag != null && !eTag.isBlank()) {
return eTag.replace("\"", "");
}
return (size == null ? -1 : size)
+ ":"
+ (lastModified == null ? 0 : lastModified.toEpochMilli());
}
}
@@ -1,6 +1,7 @@
package stirling.software.proprietary.policy.source;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.HttpStatus;
@@ -29,6 +30,7 @@ import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
import stirling.software.proprietary.util.SecretMasker;
/**
* CRUD for persisted, reusable input connections plus the Sources overview for the admin portal. A
@@ -65,11 +67,16 @@ public class SourceController {
}
@GetMapping("/{sourceId}")
@Operation(summary = "Get a source by id")
@Operation(
summary = "Get a source by id",
description =
"Secret-bearing options are returned as a redaction sentinel, never their"
+ " stored values; an edit that sends the sentinel back keeps them.")
public ResponseEntity<Source> get(@PathVariable String sourceId) {
return sourceStore
.get(sourceId)
.filter(sourceAccessGuard::canAccess)
.map(SourceController::withMaskedSecrets)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@@ -97,7 +104,7 @@ public class SourceController {
+ " matching source type.")
public ResponseEntity<Source> save(@RequestBody Source source) {
requireSourceEditingAllowed();
Source owned = resolveOwnership(source);
Source owned = withStoredSecrets(resolveOwnership(source));
try {
validateConfig(owned);
} catch (IllegalArgumentException e) {
@@ -107,7 +114,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(saved);
return ResponseEntity.ok(withMaskedSecrets(saved));
}
@DeleteMapping("/{sourceId}")
@@ -169,6 +176,42 @@ public class SourceController {
teamId);
}
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());
}
/** Secrets never leave the server: reads return the redaction sentinel in their place. */
private static Source withMaskedSecrets(Source source) {
return withOptions(source, SecretMasker.mask(source.options()));
}
/**
* An edit that round-trips a masked read sends secrets back as the sentinel; restore them from
* the stored source so saving without re-typing keeps them (validation then runs against the
* real values).
*/
private Source withStoredSecrets(Source incoming) {
if (incoming.id() == null || incoming.id().isBlank()) {
return incoming;
}
return sourceStore
.get(incoming.id())
.map(
existing ->
withOptions(
incoming,
SecretMasker.restoreRedacted(
incoming.options(), existing.options())))
.orElse(incoming);
}
/** 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();
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.source;
import java.io.Serializable;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@@ -11,6 +12,8 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.integration.crypto.LenientEncryptedStringConverter;
/**
* JPA row for a {@link Source}. The whole source lives as JSON in {@code sourceJson} (authoritative
* on read); the scalar columns are denormalized copies for querying. {@code owner} and {@code
@@ -45,6 +48,9 @@ public class SourceEntity implements Serializable {
@Column(name = "enabled")
private boolean enabled;
// Encrypted at rest: source options carry user-supplied credentials (e.g. an S3 secret
// access key). Lenient so rows written before encryption shipped still load.
@Convert(converter = LenientEncryptedStringConverter.class)
@Column(name = "source_json", columnDefinition = "text")
private String sourceJson;
}
@@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.util.SecretMasker;
/**
* Builds the Sources overview: every persisted source the caller's team owns, shown exactly once,
@@ -104,13 +105,18 @@ public class SourceOverviewService {
return referenceCount == 0 ? "unused" : "active";
}
/** Generic key/value view of the source's config - works for any source type. */
/**
* Generic key/value view of the source's config - works for any source type. Secret-bearing
* options (e.g. an S3 secret access key) are redacted, not omitted, so the overview still shows
* that a credential is configured.
*/
private static List<SourceView.DetailRow> configRows(Source source) {
return source.options().entrySet().stream()
Map<String, Object> masked = SecretMasker.mask(source.options());
return source.options().keySet().stream()
.map(
entry ->
key ->
new SourceView.DetailRow(
humanize(entry.getKey()), String.valueOf(entry.getValue())))
humanize(key), String.valueOf(masked.get(key))))
.toList();
}
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.store;
import java.io.Serializable;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@@ -11,6 +12,8 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.integration.crypto.LenientEncryptedStringConverter;
/**
* JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives
* as JSON in {@code policyJson} (authoritative on read); the scalar columns are denormalized copies
@@ -55,6 +58,9 @@ public class PolicyEntity implements Serializable {
@Column(name = "sort_order")
private Integer sortOrder;
// Encrypted at rest: output options carry user-supplied credentials (e.g. an S3 secret
// access key). Lenient so rows written before encryption shipped still load.
@Convert(converter = LenientEncryptedStringConverter.class)
@Column(name = "policy_json", columnDefinition = "text")
private String policyJson;
}
@@ -99,11 +99,17 @@ public class ScheduleTrigger implements PolicyTrigger {
// Baseline a newly-seen policy to now so it does not fire immediately.
Instant last = lastFiredByPolicy.computeIfAbsent(policy.id(), id -> now);
ZonedDateTime next = config.schedule().nextAfter(last.atZone(config.zone()));
if (!next.toInstant().isAfter(now)) {
lastFiredByPolicy.put(policy.id(), now);
log.info("Scheduled policy {} ({}) is due", policy.id(), policy.name());
policyRunner.run(policy);
if (next.toInstant().isAfter(now)) {
continue;
}
ZonedDateTime later = config.schedule().nextAfter(next);
while (!later.toInstant().isAfter(now)) {
next = later;
later = config.schedule().nextAfter(later);
}
lastFiredByPolicy.put(policy.id(), next.toInstant());
log.info("Scheduled policy {} ({}) is due", policy.id(), policy.name());
policyRunner.run(policy);
}
}
@@ -1,5 +1,6 @@
package stirling.software.proprietary.util;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
@@ -13,10 +14,15 @@ import stirling.software.common.util.RegexPatternUtils;
@Slf4j
public final class SecretMasker {
/** The placeholder masked values are replaced with; reads as "a secret is set". */
public static final String REDACTED = "********";
private static final Pattern SENSITIVE =
RegexPatternUtils.getInstance()
.getPattern(
"(?i)\\b(password|token|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() {}
@@ -47,8 +53,28 @@ public final class SecretMasker {
private static Object deepMaskValue(String key, Object value) {
if (key != null && SENSITIVE.matcher(key).find()) {
return "***REDACTED***";
return REDACTED;
}
return deepMask(value);
}
/**
* Restore top-level values the caller sent back as the {@link #REDACTED} sentinel from the
* stored map, so a masked read can round-trip through an edit without re-typing secrets. A
* sentinel with no stored counterpart is left as-is (it fails whatever validates it, rather
* than silently passing an unset secret).
*/
public static Map<String, Object> restoreRedacted(
Map<String, Object> incoming, Map<String, Object> stored) {
if (incoming == null || stored == null) {
return incoming;
}
Map<String, Object> merged = new LinkedHashMap<>(incoming);
merged.replaceAll(
(key, value) ->
REDACTED.equals(value) && stored.containsKey(key)
? stored.get(key)
: value);
return merged;
}
}
@@ -0,0 +1,45 @@
package stirling.software.proprietary.integration.crypto;
import static org.assertj.core.api.Assertions.assertThat;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class LenientEncryptedStringConverterTest {
private final LenientEncryptedStringConverter converter = new LenientEncryptedStringConverter();
@BeforeAll
static void initKey() throws Exception {
KeyGenerator generator = KeyGenerator.getInstance("AES");
generator.init(256);
SecretKey key = generator.generateKey();
CredentialEncryption.initialiseForTesting(key);
}
@Test
void roundTripsThroughCiphertext() {
String json = "{\"bucket\":\"inbox\",\"secretAccessKey\":\"shh\"}";
String stored = converter.convertToDatabaseColumn(json);
assertThat(stored).isNotEqualTo(json).doesNotContain("shh");
assertThat(converter.convertToEntityAttribute(stored)).isEqualTo(json);
}
@Test
void legacyPlaintextRowsPassThroughOnRead() {
String legacy = "{\"bucket\":\"inbox\",\"mode\":\"consume\"}";
assertThat(converter.convertToEntityAttribute(legacy)).isEqualTo(legacy);
}
@Test
void nullsPassThrough() {
assertThat(converter.convertToDatabaseColumn(null)).isNull();
assertThat(converter.convertToEntityAttribute(null)).isNull();
}
}
@@ -9,6 +9,7 @@ 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.CompletableFuture;
@@ -17,6 +18,7 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
@@ -34,7 +36,9 @@ import stirling.software.proprietary.policy.engine.PolicyRunHandle;
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.engine.PolicyValidator;
import stirling.software.proprietary.policy.engine.SweepOutcome;
import stirling.software.proprietary.policy.ledger.ProcessedLedger;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
@@ -44,6 +48,7 @@ import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.SourceAccessGuard;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
import stirling.software.proprietary.util.SecretMasker;
@ExtendWith(MockitoExtension.class)
@DisplayName("PolicyController")
@@ -128,6 +133,17 @@ class PolicyControllerTest {
return new Policy(id, "name", "owner", true, null, List.of(), List.of(), null, teamId);
}
private static Policy s3OutputPolicy(String id, String secret) {
OutputSpec output =
new OutputSpec(
"s3",
Map.of(
"bucket", "outbox",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", secret));
return new Policy(id, "name", "owner", true, null, List.of(), List.of(), output, 1L);
}
private static PolicyRunHandle handle(String runId) {
PolicyRun run = new PolicyRun(runId, null, definitionWithStep());
return new PolicyRunHandle(runId, CompletableFuture.completedFuture(run));
@@ -263,6 +279,27 @@ class PolicyControllerTest {
verify(policyTriggerManager).notifyPoliciesChanged();
}
@Test
@DisplayName("saving the sentinel back keeps the stored output secret")
void saveRestoresOutputSecrets() {
applicationProperties.getSecurity().setEnableLogin(false);
Policy existing = s3OutputPolicy("p1", "shh");
when(policyStore.get("p1")).thenReturn(Optional.of(existing));
when(policyAccessGuard.canAccess(existing)).thenReturn(true);
when(policyStore.save(any())).thenAnswer(i -> i.getArgument(0));
ResponseEntity<Policy> response =
controller.savePolicy(s3OutputPolicy("p1", SecretMasker.REDACTED));
ArgumentCaptor<Policy> stored = ArgumentCaptor.forClass(Policy.class);
verify(policyStore).save(stored.capture());
assertThat(stored.getValue().output().options().get("secretAccessKey"))
.isEqualTo("shh");
// The save response is masked again; only the store sees the real value.
assertThat(response.getBody().output().options().get("secretAccessKey"))
.isEqualTo(SecretMasker.REDACTED);
}
@Test
@DisplayName("forbidden when login enabled and caller cannot edit")
void forbidden() {
@@ -363,6 +400,20 @@ class PolicyControllerTest {
assertThat(response.getBody().id()).isEqualTo("a");
}
@Test
@DisplayName("getPolicy returns output secrets as the redaction sentinel")
void getMasksOutputSecrets() {
Policy p = s3OutputPolicy("a", "shh");
when(policyStore.get("a")).thenReturn(Optional.of(p));
when(policyAccessGuard.canAccess(p)).thenReturn(true);
Policy read = controller.getPolicy("a").getBody();
assertThat(read.output().options().get("secretAccessKey"))
.isEqualTo(SecretMasker.REDACTED);
assertThat(read.output().options().get("bucket")).isEqualTo("outbox");
}
@Test
@DisplayName("getPolicy returns 404 when not accessible")
void getNotAccessible() {
@@ -536,17 +587,18 @@ class PolicyControllerTest {
}
@Test
@DisplayName("trigger runs an accessible policy against its sources and returns run ids")
@DisplayName("trigger runs an accessible policy against its sources and returns the sweep")
void triggersRun() {
Policy p = policy("a", 1L);
when(policyStore.get("a")).thenReturn(Optional.of(p));
when(policyAccessGuard.canAccess(p)).thenReturn(true);
when(policyRunner.run(p)).thenReturn(List.of("run-a", "run-b"));
SweepOutcome outcome = new SweepOutcome(List.of("run-a", "run-b"), 3, 1, 0, 0);
when(policyRunner.run(p)).thenReturn(outcome);
ResponseEntity<List<String>> response = controller.trigger("a");
ResponseEntity<SweepOutcome> response = controller.trigger("a");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
assertThat(response.getBody()).containsExactly("run-a", "run-b");
assertThat(response.getBody()).isEqualTo(outcome);
}
@Test
@@ -1,5 +1,6 @@
package stirling.software.proprietary.policy.engine;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -30,6 +31,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.input.ResolveContext;
import stirling.software.proprietary.policy.input.ResolvedInput;
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
import stirling.software.proprietary.policy.ledger.ProcessedLedger;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.OutputSpec;
@@ -85,6 +87,42 @@ class PolicyRunnerTest {
verify(processedLedger).deleteUnseen(eq("p1"), anyLong());
}
@Test
void reportsWhatTheSweepSkippedSoAnEmptyTriggerExplainsItself() throws Exception {
InProcessProcessedLedger ledger = new InProcessProcessedLedger();
PolicyRunner reporting =
new PolicyRunner(
policyEngine,
List.of(folderSource),
sourceStore,
new InProcessSourceDocCounter(),
ledger);
InputSpec spec = InputSpec.folder("/in");
Policy policy = policy(List.of(spec));
// One file already processed at its current version, one parked by a failed run.
ledger.claim("p1", "/in/done.pdf", "g1", null);
ledger.settle("p1", "/in/done.pdf", "g1", null, true);
ledger.claim("p1", "/in/failed.pdf", "g2", null);
ledger.settle("p1", "/in/failed.pdf", "g2", null, false);
when(folderSource.supports(spec)).thenReturn(true);
when(folderSource.resolve(eq(spec), any()))
.thenAnswer(
invocation -> {
ResolveContext ctx = invocation.getArgument(1);
ctx.reportPresent(List.of("/in/done.pdf", "/in/failed.pdf"));
// Both are at their settled versions, so neither claims.
return List.of();
});
SweepOutcome outcome = reporting.run(policy);
assertTrue(outcome.runIds().isEmpty());
assertEquals(2, outcome.filesListed());
assertEquals(1, outcome.alreadyProcessed());
assertEquals(1, outcome.parked());
assertEquals(0, outcome.inFlight());
}
@Test
void pullsEverySourceAndRunsOnePerUnitOfWork() throws Exception {
InputSpec spec = InputSpec.folder("/in");
@@ -0,0 +1,231 @@
package stirling.software.proprietary.policy.input;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
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.testcontainers.containers.MinIOContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
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 software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.sync.RequestBody;
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.HeadObjectRequest;
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
/**
* End-to-end {@link S3InputSource} test against a real S3 API (MinIO), through the production
* client factory: listing, claiming, streaming, consensus delete, and save-time validation.
*/
@Testcontainers(disabledWithoutDocker = true)
class S3InputSourceMinioTest {
private static final String POLICY = "p1";
private static final String ACCESS_KEY = "minioadmin";
private static final String SECRET_KEY = "minioadmin";
@Container
static MinIOContainer minio =
new MinIOContainer("minio/minio:latest")
.withUserName(ACCESS_KEY)
.withPassword(SECRET_KEY);
private static S3Client adminClient;
private static int bucketCounter;
private String bucket;
private S3InputSource source;
private InProcessProcessedLedger ledger;
private RecordingContext ctx;
@BeforeEach
void setUp() {
if (adminClient == null) {
adminClient =
S3Client.builder()
.endpointOverride(java.net.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 = "policy-inbox-" + ++bucketCounter;
adminClient.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
// 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));
ledger = new InProcessProcessedLedger();
ctx = new RecordingContext();
}
@Test
void consumeListsStreamsAndDeletesByConsensus() throws IOException {
put("incoming/doc.pdf", "pdf bytes");
put("incoming/other.txt", "text");
List<ResolvedInput> work = source.resolve(spec(Map.of("prefix", "incoming/")), ctx);
assertThat(work).hasSize(2);
assertThat(ctx.present)
.containsExactlyInAnyOrder(
"s3://" + bucket + "/incoming/doc.pdf",
"s3://" + bucket + "/incoming/other.txt");
assertThat(read(work.get(0))).isIn("pdf bytes", "text");
// In flight: nothing to claim on a second sweep.
assertThat(source.resolve(spec(Map.of("prefix", "incoming/")), ctx)).isEmpty();
work.forEach(unit -> unit.onComplete().accept(true));
assertThat(exists("incoming/doc.pdf")).isFalse();
assertThat(exists("incoming/other.txt")).isFalse();
}
@Test
void aFailedObjectStaysInTheBucket() throws IOException {
put("doc.pdf", "data");
source.resolve(spec(Map.of()), ctx).get(0).onComplete().accept(false);
assertThat(exists("doc.pdf")).isTrue();
assertThat(source.resolve(spec(Map.of()), ctx)).isEmpty();
}
@Test
void anObjectOverwrittenMidRunSurvivesTheDeleteAndRunsAgain() throws IOException {
put("doc.pdf", "v1");
List<ResolvedInput> work = source.resolve(spec(Map.of()), ctx);
put("doc.pdf", "v2 with a different etag");
work.get(0).onComplete().accept(true);
assertThat(exists("doc.pdf")).isTrue();
assertThat(source.resolve(spec(Map.of()), ctx)).hasSize(1);
}
@Test
void prefixLimitsWhatIsRead() throws IOException {
put("incoming/doc.pdf", "data");
put("archive/old.pdf", "data");
List<ResolvedInput> work = source.resolve(spec(Map.of("prefix", "incoming/")), ctx);
assertThat(work).hasSize(1);
assertThat(ctx.present).containsExactly("s3://" + bucket + "/incoming/doc.pdf");
}
@Test
void validateAcceptsAReachableBucketAndRejectsBadCredentials() {
source.validate(spec(Map.of()));
Map<String, Object> wrongSecret = new HashMap<>(baseOptions());
wrongSecret.put("secretAccessKey", "not-the-secret");
assertThatThrownBy(() -> source.validate(new InputSpec("s3", wrongSecret)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("cannot access");
Map<String, Object> missingBucket = new HashMap<>(baseOptions());
missingBucket.put("bucket", "no-such-bucket-here");
assertThatThrownBy(() -> source.validate(new InputSpec("s3", missingBucket)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("cannot access");
}
@Test
void aPrivateEndpointIsRejectedWithoutTheOperatorOptIn() {
S3InputSource guarded =
new S3InputSource(new S3ConnectionPool(new ApplicationProperties()));
assertThatThrownBy(() -> guarded.validate(spec(Map.of())))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("policies.allowPrivateS3Endpoints");
}
private Map<String, Object> baseOptions() {
return Map.of(
"bucket", bucket,
"endpoint", minio.getS3URL(),
"accessKeyId", ACCESS_KEY,
"secretAccessKey", SECRET_KEY);
}
private InputSpec spec(Map<String, Object> extra) {
Map<String, Object> options = new HashMap<>(baseOptions());
options.putAll(extra);
return new InputSpec("s3", options);
}
private void put(String key, String content) {
adminClient.putObject(
PutObjectRequest.builder().bucket(bucket).key(key).build(),
RequestBody.fromString(content, StandardCharsets.UTF_8));
}
private boolean exists(String key) {
try {
adminClient.headObject(HeadObjectRequest.builder().bucket(bucket).key(key).build());
return true;
} catch (NoSuchKeyException e) {
return false;
}
}
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);
}
}
}
@@ -0,0 +1,327 @@
package stirling.software.proprietary.policy.input;
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.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
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.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
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 software.amazon.awssdk.core.ResponseInputStream;
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.http.AbortableInputStream;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
import software.amazon.awssdk.services.s3.model.S3Object;
/**
* Tests for {@link S3InputSource}: consume mode tracks objects in place through the ledger and
* removes them by consensus, snapshot stays stateless, and discovery skips folder placeholders and
* dot-prefixed keys.
*/
@ExtendWith(MockitoExtension.class)
class S3InputSourceTest {
private static final String POLICY = "p1";
private static final String BUCKET = "inbox-bucket";
@Mock private S3Client s3Client;
private S3InputSource source;
private InProcessProcessedLedger ledger;
private RecordingContext ctx;
@BeforeEach
void setUp() {
source =
new S3InputSource(
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client));
ledger = new InProcessProcessedLedger();
ctx = new RecordingContext();
}
@Test
void consumeRemovesTheObjectOnceProcessed() throws IOException {
listingReturns(object("doc.pdf", "\"etag-1\""));
headReturns("doc.pdf", "\"etag-1\"");
List<ResolvedInput> work = source.resolve(spec(), ctx);
assertEquals(1, work.size());
assertEquals(1, work.get(0).inputs().primary().size());
// In flight: a second sweep does not pick it up again.
assertTrue(source.resolve(spec(), ctx).isEmpty());
work.get(0).onComplete().accept(true);
verify(s3Client).deleteObject(any(DeleteObjectRequest.class));
assertTrue(source.resolve(spec(), ctx).isEmpty());
}
@Test
void anObjectReplacedMidRunSurvivesTheDelete() throws IOException {
listingReturns(object("doc.pdf", "\"etag-1\""));
// The object is overwritten while the run is executing.
headReturns("doc.pdf", "\"etag-2\"");
List<ResolvedInput> work = source.resolve(spec(), ctx);
work.get(0).onComplete().accept(true);
// The delete is version-guarded: the replacement is not the object that ran, so it stays
// and is claimed as fresh work instead of being marked processed.
verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class));
listingReturns(object("doc.pdf", "\"etag-2\""));
assertEquals(1, source.resolve(spec(), ctx).size());
}
@Test
void aSharedObjectIsRemovedOnlyOnceEveryPolicyHasProcessedIt() throws IOException {
listingReturns(object("doc.pdf", "\"etag-1\""));
headReturns("doc.pdf", "\"etag-1\"");
RecordingContext other = new RecordingContext("p2");
List<ResolvedInput> mine = source.resolve(spec(), ctx);
List<ResolvedInput> theirs = source.resolve(spec(), other);
assertEquals(1, mine.size());
assertEquals(1, theirs.size());
mine.get(0).onComplete().accept(true);
// The other policy's claim is still in flight, so the first finisher must not delete.
verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class));
theirs.get(0).onComplete().accept(true);
verify(s3Client).deleteObject(any(DeleteObjectRequest.class));
}
@Test
void aFailedObjectStaysAndIsNotRetriedUntilItChanges() throws IOException {
listingReturns(object("doc.pdf", "\"etag-1\""));
source.resolve(spec(), ctx).get(0).onComplete().accept(false);
verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class));
assertTrue(source.resolve(spec(), ctx).isEmpty());
// A new upload carries a new ETag, which reads as a new version and retries.
listingReturns(object("doc.pdf", "\"etag-2\""));
assertEquals(1, source.resolve(spec(), ctx).size());
}
@Test
void snapshotReadsStatelesslyEverySweep() throws IOException {
listingReturns(object("doc.pdf", "\"etag-1\""));
InputSpec spec = new InputSpec("s3", options(Map.of("mode", "snapshot")));
List<ResolvedInput> first = source.resolve(spec, ctx);
first.get(0).onComplete().accept(true);
List<ResolvedInput> second = source.resolve(spec, ctx);
assertEquals(1, first.size());
assertEquals(1, second.size());
verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class));
assertTrue(ctx.present.isEmpty());
}
@Test
void folderPlaceholdersAndDotPrefixedKeysAreSkipped() throws IOException {
listingReturns(
object("doc.pdf", "\"etag-1\""),
object("incoming/", "\"etag-2\""),
object(".stirling/tmp/staged.pdf", "\"etag-3\""),
object("incoming/.hidden.pdf", "\"etag-4\""));
List<ResolvedInput> work = source.resolve(spec(), ctx);
assertEquals(1, work.size());
assertEquals(List.of("s3://" + BUCKET + "/doc.pdf"), ctx.present);
}
@Test
void listingPagesAreAllRead() throws IOException {
ListObjectsV2Response firstPage =
ListObjectsV2Response.builder()
.contents(object("a.pdf", "\"etag-a\""))
.nextContinuationToken("next")
.build();
ListObjectsV2Response secondPage =
ListObjectsV2Response.builder().contents(object("b.pdf", "\"etag-b\"")).build();
when(s3Client.listObjectsV2(any(ListObjectsV2Request.class)))
.thenReturn(firstPage, secondPage);
assertEquals(2, source.resolve(spec(), ctx).size());
}
@Test
void aListingFailurePropagatesSoTheSweepVetoesCleanup() {
when(s3Client.listObjectsV2(any(ListObjectsV2Request.class)))
.thenThrow(SdkClientException.create("connection refused"));
assertThrows(SdkClientException.class, () -> source.resolve(spec(), ctx));
}
@Test
void resourceStreamsTheObjectAndNamesItByKeyBasename() throws IOException {
listingReturns(object("incoming/doc.pdf", "\"etag-1\""));
byte[] payload = "data".getBytes(StandardCharsets.UTF_8);
when(s3Client.getObject(any(GetObjectRequest.class)))
.thenReturn(
new ResponseInputStream<>(
GetObjectResponse.builder().build(),
AbortableInputStream.create(new ByteArrayInputStream(payload))));
var resource = source.resolve(spec(), ctx).get(0).inputs().primary().get(0);
assertEquals("doc.pdf", resource.getFilename());
// Content length comes from the listing, not a download.
assertEquals(4, resource.contentLength());
try (var stream = resource.getInputStream()) {
assertEquals("data", new String(stream.readAllBytes(), StandardCharsets.UTF_8));
}
}
@Test
void aMissingETagFallsBackToSizeAndLastModified() throws IOException {
Instant modified = Instant.parse("2026-01-01T00:00:00Z");
listingReturns(S3Object.builder().key("doc.pdf").size(4L).lastModified(modified).build());
assertEquals(1, source.resolve(spec(), ctx).size());
// The same gate on the next sweep reads as already claimed.
listingReturns(S3Object.builder().key("doc.pdf").size(4L).lastModified(modified).build());
assertTrue(source.resolve(spec(), ctx).isEmpty());
}
@Test
void validateRejectsBadConfig() {
// No bucket.
assertThrows(
IllegalArgumentException.class,
() -> source.validate(new InputSpec("s3", Map.of())));
// Credentials are required, never the server's own identity - together and individually.
assertThrows(
IllegalArgumentException.class,
() -> source.validate(new InputSpec("s3", Map.of("bucket", BUCKET))));
assertThrows(
IllegalArgumentException.class,
() ->
source.validate(
new InputSpec(
"s3", Map.of("bucket", BUCKET, "accessKeyId", "AKIA"))));
assertThrows(
IllegalArgumentException.class,
() -> source.validate(new InputSpec("s3", options(Map.of("mode", "sideways")))));
assertThrows(
IllegalArgumentException.class,
() ->
source.validate(
new InputSpec(
"s3", options(Map.of("endpoint", "ftp://example.com")))));
}
@Test
void validateRejectsAnUnreachableBucket() {
when(s3Client.listObjectsV2(any(ListObjectsV2Request.class)))
.thenThrow(SdkClientException.create("connection refused"));
assertThrows(IllegalArgumentException.class, () -> source.validate(spec()));
}
private static InputSpec spec() {
return new InputSpec("s3", options(Map.of()));
}
/** The required options (bucket + credentials) plus any extras under test. */
private static Map<String, Object> options(Map<String, Object> extra) {
Map<String, Object> options = new HashMap<>(extra);
options.put("bucket", BUCKET);
options.put("accessKeyId", "AKIAEXAMPLE");
options.put("secretAccessKey", "shh");
return options;
}
private static S3Object object(String key, String eTag) {
return S3Object.builder()
.key(key)
.eTag(eTag)
.size(4L)
.lastModified(Instant.parse("2026-01-01T00:00:00Z"))
.build();
}
private void listingReturns(S3Object... objects) {
when(s3Client.listObjectsV2(any(ListObjectsV2Request.class)))
.thenReturn(ListObjectsV2Response.builder().contents(objects).build());
}
private void headReturns(String key, String eTag) {
when(s3Client.headObject(any(HeadObjectRequest.class)))
.thenReturn(
HeadObjectResponse.builder()
.eTag(eTag)
.contentLength(4L)
.lastModified(Instant.parse("2026-01-01T00:00:00Z"))
.build());
}
private class RecordingContext implements ResolveContext {
private final String policyId;
private final List<String> present = new ArrayList<>();
private RecordingContext() {
this(POLICY);
}
private RecordingContext(String policyId) {
this.policyId = policyId;
}
@Override
public boolean claim(String identity, String gate, Supplier<String> contentHash) {
return ledger.claim(policyId, identity, gate, contentHash);
}
@Override
public void settle(
String identity, String finalGate, String finalContentHash, boolean success) {
ledger.settle(policyId, identity, finalGate, finalContentHash, success);
}
@Override
public boolean allSettledDone(String identity) {
return ledger.allSettledDone(identity);
}
@Override
public void reportPresent(Collection<String> identities) {
present.addAll(identities);
}
}
}
@@ -0,0 +1,211 @@
package stirling.software.proprietary.policy.output;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
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.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
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.model.job.ResultFile;
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.ledger.InProcessProcessedLedger;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.ResponseInputStream;
import software.amazon.awssdk.core.sync.RequestBody;
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.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
/**
* End-to-end {@link S3OutputSink} test against a real S3 API (MinIO): uploads, collision renaming,
* and - composed with {@link S3InputSource} - the loop-safety guarantee that a policy writing into
* a bucket it also watches never re-ingests its own outputs, while a second policy still can.
*/
@Testcontainers(disabledWithoutDocker = true)
class S3OutputSinkMinioTest {
private static final String POLICY = "p1";
private static final String ACCESS_KEY = "minioadmin";
private static final String SECRET_KEY = "minioadmin";
@Container
static MinIOContainer minio =
new MinIOContainer("minio/minio:latest")
.withUserName(ACCESS_KEY)
.withPassword(SECRET_KEY);
private static S3Client adminClient;
private static int bucketCounter;
private String bucket;
private S3OutputSink sink;
private S3InputSource source;
private InProcessProcessedLedger ledger;
@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 = "policy-outbox-" + ++bucketCounter;
adminClient.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
ApplicationProperties properties = new ApplicationProperties();
properties.getPolicies().setAllowPrivateS3Endpoints(true);
S3ConnectionPool pool = new S3ConnectionPool(properties);
ledger = new InProcessProcessedLedger();
sink = new S3OutputSink(pool, ledger);
source = new S3InputSource(pool);
}
@Test
void uploadsOutputsUnderThePrefix() throws IOException {
List<ResultFile> results =
sink.deliver(
new OutputDelivery("run-1", POLICY),
List.of(output("doc.pdf", "pdf bytes")),
outputSpec("processed/"));
assertThat(results).hasSize(1);
assertThat(results.get(0).getFileName()).isEqualTo("s3://" + bucket + "/processed/doc.pdf");
assertThat(objectContent("processed/doc.pdf")).isEqualTo("pdf bytes");
}
@Test
void anExistingKeyIsNeverOverwritten() throws IOException {
adminClient.putObject(
PutObjectRequest.builder().bucket(bucket).key("doc.pdf").build(),
RequestBody.fromString("theirs", StandardCharsets.UTF_8));
List<ResultFile> results =
sink.deliver(
new OutputDelivery("run-1", POLICY),
List.of(output("doc.pdf", "ours")),
outputSpec(""));
assertThat(results.get(0).getFileName()).isEqualTo("s3://" + bucket + "/doc (1).pdf");
assertThat(objectContent("doc.pdf")).isEqualTo("theirs");
assertThat(objectContent("doc (1).pdf")).isEqualTo("ours");
}
@Test
void aPolicyWritingIntoItsWatchedBucketSkipsItsOwnOutputsButAnotherPolicyChains()
throws IOException {
sink.deliver(
new OutputDelivery("run-1", POLICY),
List.of(output("result.pdf", "produced")),
outputSpec(""));
// The producing policy's sweep sees its own output at the recorded gate and skips it.
assertThat(source.resolve(inputSpec(), new RecordingContext(POLICY))).isEmpty();
// A different policy watching the same bucket has no row and processes it - chaining.
List<ResolvedInput> chained = source.resolve(inputSpec(), new RecordingContext("p2"));
assertThat(chained).hasSize(1);
try (InputStream stream = chained.get(0).inputs().primary().get(0).getInputStream()) {
assertThat(new String(stream.readAllBytes(), StandardCharsets.UTF_8))
.isEqualTo("produced");
}
}
private OutputSpec outputSpec(String prefix) {
return new OutputSpec(
"s3",
Map.of(
"bucket", bucket,
"prefix", prefix,
"endpoint", minio.getS3URL(),
"accessKeyId", ACCESS_KEY,
"secretAccessKey", SECRET_KEY));
}
private InputSpec inputSpec() {
return new InputSpec(
"s3",
Map.of(
"bucket", bucket,
"endpoint", minio.getS3URL(),
"accessKeyId", ACCESS_KEY,
"secretAccessKey", SECRET_KEY));
}
private String objectContent(String key) throws IOException {
try (ResponseInputStream<GetObjectResponse> stream =
adminClient.getObject(GetObjectRequest.builder().bucket(bucket).key(key).build())) {
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
}
private static Resource output(String name, String content) {
return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)) {
@Override
public String getFilename() {
return name;
}
};
}
private class RecordingContext implements ResolveContext {
private final String policyId;
private RecordingContext(String policyId) {
this.policyId = policyId;
}
@Override
public boolean claim(String identity, String gate, Supplier<String> contentHash) {
return ledger.claim(policyId, identity, gate, contentHash);
}
@Override
public void settle(
String identity, String finalGate, String finalContentHash, boolean success) {
ledger.settle(policyId, identity, finalGate, finalContentHash, success);
}
@Override
public boolean allSettledDone(String identity) {
return ledger.allSettledDone(identity);
}
@Override
public void reportPresent(Collection<String> identities) {}
}
}
@@ -0,0 +1,267 @@
package stirling.software.proprietary.policy.output;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
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.Mockito.when;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
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 org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.job.ResultFile;
import stirling.software.proprietary.policy.ledger.ClaimState;
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 software.amazon.awssdk.awscore.exception.AwsServiceException;
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
import software.amazon.awssdk.services.s3.model.S3Exception;
/**
* Tests for {@link S3OutputSink}: the ledger row exists before the object is visible, collisions
* re-pick names, ad-hoc runs record nothing, and encrypted-bucket ETags are re-recorded.
*/
@ExtendWith(MockitoExtension.class)
class S3OutputSinkTest {
private static final String POLICY = "p1";
private static final String BUCKET = "outbox-bucket";
private static final OutputDelivery DELIVERY = new OutputDelivery("run-1", POLICY);
private static final OutputDelivery AD_HOC = new OutputDelivery("run-2", null);
@Mock private S3Client s3Client;
private S3OutputSink sink;
private InProcessProcessedLedger ledger;
private final List<PutObjectRequest> puts = new ArrayList<>();
@BeforeEach
void setUp() {
ledger = new InProcessProcessedLedger();
sink =
new S3OutputSink(
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client),
ledger);
}
@Test
void recordsTheRowBeforeTheObjectBecomesVisible() throws IOException {
// The row for the exact key must already be settled DONE at the moment the PUT runs -
// record-before-visible, asserted from inside the upload itself.
List<ClaimState> stateAtPutTime = new ArrayList<>();
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
.thenAnswer(
invocation -> {
PutObjectRequest request = invocation.getArgument(0);
puts.add(request);
stateAtPutTime.add(stateFor(identity(request.key())));
return PutObjectResponse.builder().eTag(quotedMd5("data")).build();
});
List<ResultFile> results =
sink.deliver(DELIVERY, List.of(output("doc.pdf", "data")), spec());
assertEquals(1, results.size());
assertEquals("s3://" + BUCKET + "/processed/doc.pdf", results.get(0).getFileName());
assertEquals(4, results.get(0).getFileSize());
assertNotNull(stateAtPutTime.get(0));
assertEquals(ProcessedFileStatus.DONE, stateAtPutTime.get(0).status());
assertEquals(md5("data"), stateAtPutTime.get(0).gate());
assertTrue(puts.get(0).ifNoneMatch() != null);
}
@Test
void aTakenKeyIsForgottenAndRePicked() throws IOException {
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
.thenAnswer(
invocation -> {
PutObjectRequest request = invocation.getArgument(0);
puts.add(request);
if (puts.size() == 1) {
throw s3Error(412, "PreconditionFailed");
}
return PutObjectResponse.builder().eTag(quotedMd5("data")).build();
});
List<ResultFile> results =
sink.deliver(DELIVERY, List.of(output("doc.pdf", "data")), spec());
assertEquals("s3://" + BUCKET + "/processed/doc (1).pdf", results.get(0).getFileName());
// The lost candidate's row is gone; only the delivered key is recorded.
assertNull(stateFor(identity("processed/doc.pdf")));
assertNotNull(stateFor(identity("processed/doc (1).pdf")));
}
@Test
void anEncryptedBucketETagIsReRecordedAtTheActualGate() throws IOException {
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
.thenReturn(PutObjectResponse.builder().eTag("\"kms-opaque-etag\"").build());
sink.deliver(DELIVERY, List.of(output("doc.pdf", "data")), spec());
assertEquals("kms-opaque-etag", stateFor(identity("processed/doc.pdf")).gate());
}
@Test
void anAdHocDeliveryRecordsNothing() throws IOException {
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
.thenReturn(PutObjectResponse.builder().eTag(quotedMd5("data")).build());
sink.deliver(AD_HOC, List.of(output("doc.pdf", "data")), spec());
assertNull(stateFor(identity("processed/doc.pdf")));
}
@Test
void aFailedUploadForgetsItsRowAndThrows() {
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
.thenThrow(SdkClientException.create("connection refused"));
assertThrows(
IOException.class,
() -> sink.deliver(DELIVERY, List.of(output("doc.pdf", "data")), spec()));
assertNull(stateFor(identity("processed/doc.pdf")));
}
@Test
void aStoreWithoutConditionalPutsFallsBackToExistenceChecks() throws IOException {
when(s3Client.headObject(any(HeadObjectRequest.class))).thenThrow(s3Error(404, "NotFound"));
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
.thenAnswer(
invocation -> {
PutObjectRequest request = invocation.getArgument(0);
puts.add(request);
if (request.ifNoneMatch() != null) {
throw s3Error(501, "NotImplemented");
}
return PutObjectResponse.builder().eTag(quotedMd5("data")).build();
});
List<ResultFile> results =
sink.deliver(DELIVERY, List.of(output("doc.pdf", "data")), spec());
// Same key, second attempt unconditional.
assertEquals("s3://" + BUCKET + "/processed/doc.pdf", results.get(0).getFileName());
assertEquals(2, puts.size());
assertNull(puts.get(1).ifNoneMatch());
assertNotNull(stateFor(identity("processed/doc.pdf")));
}
@Test
void aBarePrefixGetsItsSlash() throws IOException {
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
.thenAnswer(
invocation -> {
puts.add(invocation.getArgument(0));
return PutObjectResponse.builder().eTag(quotedMd5("data")).build();
});
sink.deliver(DELIVERY, List.of(output("doc.pdf", "data")), spec("processed"));
assertEquals("processed/doc.pdf", puts.get(0).key());
}
@Test
void validateRejectsBadConfigShape() {
assertThrows(
IllegalArgumentException.class,
() -> sink.validate(new OutputSpec("s3", Map.of())));
// Credentials are required, never the server's own identity.
assertThrows(
IllegalArgumentException.class,
() -> sink.validate(new OutputSpec("s3", Map.of("bucket", BUCKET))));
assertThrows(
IllegalArgumentException.class,
() ->
sink.validate(
new OutputSpec(
"s3", Map.of("bucket", BUCKET, "accessKeyId", "AKIA"))));
}
@Test
void supportsOnlyS3Specs() {
assertTrue(sink.supports(spec()));
assertFalse(sink.supports(OutputSpec.inline()));
assertFalse(sink.supports(null));
}
private static OutputSpec spec() {
return spec("processed/");
}
private static OutputSpec spec(String prefix) {
return new OutputSpec(
"s3",
Map.of(
"bucket",
BUCKET,
"prefix",
prefix,
"accessKeyId",
"AKIAEXAMPLE",
"secretAccessKey",
"shh"));
}
private static String identity(String key) {
return "s3://" + BUCKET + "/" + key;
}
private ClaimState stateFor(String identity) {
return ledger.statesFor(POLICY, List.of(identity)).get(identity);
}
private static Resource output(String name, String content) {
return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)) {
@Override
public String getFilename() {
return name;
}
};
}
private static String md5(String content) {
try {
return HexFormat.of()
.formatHex(
MessageDigest.getInstance("MD5")
.digest(content.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
private static String quotedMd5(String content) {
return "\"" + md5(content) + "\"";
}
private static AwsServiceException s3Error(int status, String code) {
return S3Exception.builder().statusCode(status).message(code).build();
}
}
@@ -28,6 +28,7 @@ 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.util.SecretMasker;
/**
* Tests for {@link SourceController}'s delete guard: a source still referenced by a policy is
@@ -109,6 +110,80 @@ class SourceControllerTest {
assertEquals(404, controller.delete("nope").getStatusCode().value());
}
@Test
void readsReturnSecretsAsTheRedactionSentinel() {
Source saved = sourceStore.save(s3Source("shh"));
Source read = controller.get(saved.id()).getBody();
assertEquals(SecretMasker.REDACTED, read.options().get("secretAccessKey"));
assertEquals("AKIAEXAMPLE", read.options().get("accessKeyId"));
// The store itself keeps the real value.
assertEquals(
"shh", sourceStore.get(saved.id()).orElseThrow().options().get("secretAccessKey"));
}
@Test
void savingTheSentinelBackKeepsTheStoredSecret() {
Source saved = sourceStore.save(s3Source("shh"));
Source edited =
new Source(
saved.id(),
"Renamed",
saved.type(),
Map.of(
"bucket", "inbox",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", SecretMasker.REDACTED),
true,
saved.owner(),
saved.teamId());
Source response = controller.save(edited).getBody();
assertEquals(
"shh", sourceStore.get(saved.id()).orElseThrow().options().get("secretAccessKey"));
// The save response is masked too; only the store sees the real value.
assertEquals(SecretMasker.REDACTED, response.options().get("secretAccessKey"));
}
@Test
void savingANewSecretReplacesTheStoredOne() {
Source saved = sourceStore.save(s3Source("old-secret"));
Source edited =
new Source(
saved.id(),
saved.name(),
saved.type(),
Map.of(
"bucket", "inbox",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "new-secret"),
true,
saved.owner(),
saved.teamId());
controller.save(edited);
assertEquals(
"new-secret",
sourceStore.get(saved.id()).orElseThrow().options().get("secretAccessKey"));
}
private static Source s3Source(String secret) {
return new Source(
null,
"Bucket intake",
"s3",
Map.of(
"bucket", "inbox",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", secret),
true,
"owner",
null);
}
private static Source folderSource() {
return new Source(
null, "Claims intake", "folder", Map.of("directory", "/in"), true, "owner", null);
@@ -70,6 +70,41 @@ class ScheduleTriggerTest {
verify(policyRunner, times(1)).run(eq(policy));
}
@Test
void anIntervalMatchingTheSweepPeriodFiresEverySweepDespiteJitter() {
Policy policy = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES));
when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy));
Instant t0 = Instant.parse("2026-06-05T10:00:00Z");
trigger.sweep(t0); // baseline
// The sweep that fires runs a few ms late (scheduler jitter)...
trigger.sweep(t0.plusSeconds(60).plusMillis(5));
verify(policyRunner, times(1)).run(eq(policy));
// ...and the next sweep lands exactly on the 60s grid. Anchoring lastFired to the due
// time (not the jittered observation) means this must still fire, not alias to skip.
trigger.sweep(t0.plusSeconds(120));
verify(policyRunner, times(2)).run(eq(policy));
}
@Test
void aGapFiresOnceNotOncePerMissedInterval() {
Policy policy = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES));
when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy));
Instant t0 = Instant.parse("2026-06-05T10:00:00Z");
trigger.sweep(t0); // baseline
// Ten minutes of downtime: nine missed due points collapse into one firing.
trigger.sweep(t0.plusSeconds(600));
verify(policyRunner, times(1)).run(eq(policy));
// Not due again until a full interval after the latest due point.
trigger.sweep(t0.plusSeconds(630));
verify(policyRunner, times(1)).run(eq(policy));
trigger.sweep(t0.plusSeconds(660));
verify(policyRunner, times(2)).run(eq(policy));
}
@Test
void doesNotFireBeforeTheNextScheduledTime() {
Policy policy = scheduled("p1", new Schedule.Daily(LocalTime.of(3, 0))); // 03:00 UTC daily
@@ -14,8 +14,8 @@ import org.junit.jupiter.api.Test;
* Unit tests for {@link SecretMasker}.
*
* <p>Assumptions: - Key matching is case-insensitive via the pattern in SENSITIVE. - If the key
* matches a sensitive pattern, the value is replaced with "***REDACTED***". - Nested maps and lists
* are searched recursively. - Null maps and null values are ignored or returned as null. -
* matches a sensitive pattern, the value is replaced with SecretMasker.REDACTED. - Nested maps and
* lists are searched recursively. - Null maps and null values are ignored or returned as null. -
* Non-sensitive keys/values remain unchanged.
*/
class SecretMaskerTest {
@@ -40,7 +40,7 @@ class SecretMaskerTest {
Map<String, Object> result = SecretMasker.mask(input);
assertEquals("***REDACTED***", result.get("password"));
assertEquals(SecretMasker.REDACTED, result.get("password"));
assertEquals("john", result.get("username"));
}
@@ -55,11 +55,54 @@ class SecretMaskerTest {
Map<String, Object> result = SecretMasker.mask(input);
assertEquals("***REDACTED***", result.get("Api-Key"));
assertEquals("***REDACTED***", result.get("TOKEN"));
assertEquals(SecretMasker.REDACTED, result.get("Api-Key"));
assertEquals(SecretMasker.REDACTED, result.get("TOKEN"));
assertEquals("keepme", result.get("normal"));
}
@Test
@DisplayName("restoreRedacted swaps sentinels for stored values, leaves the rest")
void restoreRedactedRoundTripsAnEdit() {
Map<String, Object> stored =
Map.of("secretAccessKey", "shh", "accessKeyId", "AKIAEXAMPLE");
Map<String, Object> incoming =
Map.of(
"secretAccessKey", SecretMasker.REDACTED,
"accessKeyId", "AKIA-NEW",
"bucket", "inbox");
Map<String, Object> merged = SecretMasker.restoreRedacted(incoming, stored);
assertEquals("shh", merged.get("secretAccessKey"));
assertEquals("AKIA-NEW", merged.get("accessKeyId"));
assertEquals("inbox", merged.get("bucket"));
}
@Test
@DisplayName("restoreRedacted leaves a sentinel with no stored counterpart in place")
void restoreRedactedWithoutStoredValueStaysSentinel() {
Map<String, Object> merged =
SecretMasker.restoreRedacted(
Map.of("secretAccessKey", SecretMasker.REDACTED), Map.of());
assertEquals(SecretMasker.REDACTED, merged.get("secretAccessKey"));
}
@Test
@DisplayName("should mask camelCase secretAccessKey despite no word boundary")
void shouldMaskCamelCaseSecretAccessKey() {
Map<String, Object> input =
Map.of(
"secretAccessKey", "shh",
"accessKeyId", "AKIAEXAMPLE");
Map<String, Object> result = SecretMasker.mask(input);
assertEquals(SecretMasker.REDACTED, result.get("secretAccessKey"));
// Access key ids are username-like, not secrets.
assertEquals("AKIAEXAMPLE", result.get("accessKeyId"));
}
@Test
@DisplayName("should mask nested map sensitive keys")
void shouldMaskNestedMapSensitiveKeys() {
@@ -77,9 +120,9 @@ class SecretMaskerTest {
Map<String, Object> result = SecretMasker.mask(input);
Map<String, Object> outer = (Map<String, Object>) result.get("outer");
assertEquals("***REDACTED***", outer.get("jwt"));
assertEquals(SecretMasker.REDACTED, outer.get("jwt"));
Map<String, Object> inner = (Map<String, Object>) outer.get("inner");
assertEquals("***REDACTED***", inner.get("secret"));
assertEquals(SecretMasker.REDACTED, inner.get("secret"));
assertEquals("ok", inner.get("other"));
}
@@ -98,7 +141,7 @@ class SecretMaskerTest {
List<?> list = (List<?>) result.get("list");
Map<String, Object> first = (Map<String, Object>) list.get(0);
assertEquals("***REDACTED***", first.get("token"));
assertEquals(SecretMasker.REDACTED, first.get("token"));
Map<String, Object> second = (Map<String, Object>) list.get(1);
assertEquals("john", second.get("username"));
assertEquals("stringValue", list.get(2));
@@ -170,7 +213,8 @@ class SecretMaskerTest {
Map<String, Object> outer = (Map<String, Object>) result.get("outer");
assertTrue(outer.containsKey(null), "Null key should be preserved");
assertEquals("plainText", outer.get(null), "Value for null key must not be masked");
assertEquals("***REDACTED***", outer.get("password"), "Sensitive keys must be masked");
assertEquals(
SecretMasker.REDACTED, outer.get("password"), "Sensitive keys must be masked");
}
}
}
@@ -32,6 +32,8 @@ import stirling.software.proprietary.security.repository.TeamMembershipRepositor
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
import stirling.software.saas.procurement.model.ProcurementDeal;
import stirling.software.saas.procurement.model.ProcurementQuote;
import stirling.software.saas.procurement.model.QuoteDetails;
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
import stirling.software.saas.procurement.pricing.QuoteConfig;
import stirling.software.saas.procurement.pricing.QuoteLineItem;
import stirling.software.saas.procurement.service.ProcurementService;
@@ -56,16 +58,19 @@ public class ProcurementController {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final ProcurementService procurement;
private final ProcurementPricingService pricing;
private final TeamMembershipRepository memberRepo;
private final UserRepository userRepository;
private final ProcurementConfigurationProperties config;
public ProcurementController(
ProcurementService procurement,
ProcurementPricingService pricing,
TeamMembershipRepository memberRepo,
UserRepository userRepository,
ProcurementConfigurationProperties config) {
this.procurement = Objects.requireNonNull(procurement);
this.pricing = Objects.requireNonNull(pricing);
this.memberRepo = Objects.requireNonNull(memberRepo);
this.userRepository = Objects.requireNonNull(userRepository);
this.config = Objects.requireNonNull(config);
@@ -77,29 +82,53 @@ public class ProcurementController {
long volume,
int users,
int intensity, // policy posture (runs/PDF): 2 / 4 / 7; 0 → default Governed
double sizeMult, // PDF-size tier multiplier: 1.0 / 1.4 / 2.4; 0 → no uplift
String deployment,
int termYears,
String serviceLevel,
boolean indemnification,
boolean training,
boolean qbr,
boolean offlineLicense,
String currency,
String businessName) {
String businessName,
// Buyer / AP details (all optional). Country + currency intentionally out of scope.
String contactName,
String contactEmail,
String addressLine1,
String addressLine2,
String city,
String region,
String postalCode,
String poNumber,
String taxId) {
QuoteConfig toConfig() {
return new QuoteConfig(
volume,
users,
intensity,
sizeMult,
deployment,
termYears,
serviceLevel,
indemnification,
training,
qbr,
offlineLicense,
currency);
}
QuoteDetails toDetails() {
return new QuoteDetails(
businessName,
contactName,
contactEmail,
addressLine1,
addressLine2,
city,
region,
postalCode,
poNumber,
taxId);
}
}
public record QuoteResponse(
@@ -109,10 +138,15 @@ public class ProcurementController {
String currency,
long annualNetMinor,
long tcvMinor,
// First post-term renewal fee after the CPI escalator, and that escalator as a whole
// percent — the committed term is flat, so these describe only the auto-renewal.
long renewalAnnualNetMinor,
int cpiRatePct,
List<QuoteLineItem> lineItems,
String validUntil,
String stripeQuoteId,
String invoiceUrl,
String invoicePdf,
QuoteConfigEcho config) {}
/**
@@ -124,19 +158,33 @@ public class ProcurementController {
long volume,
int users,
int intensity,
double sizeMult,
String deployment,
int termYears,
String serviceLevel,
boolean indemnification,
boolean training,
boolean qbr,
boolean offlineLicense,
String currency,
String businessName) {}
String businessName,
String contactName,
String contactEmail,
String addressLine1,
String addressLine2,
String city,
String region,
String postalCode,
String poNumber,
String taxId) {}
/** Trial setup captured before the trial starts: deployment target + seat count. */
public record StartTrialRequest(String deployment, int users) {}
public record SnapshotResponse(
Long dealId,
String stage,
String deployment,
int seats,
String trialStartedAt,
String trialEndsAt,
int trialExtensionsUsed,
@@ -165,12 +213,12 @@ public class ProcurementController {
}
private static final SnapshotResponse EMPTY_SNAPSHOT =
new SnapshotResponse(null, null, null, null, 0, false, null, null);
new SnapshotResponse(null, null, null, 0, null, null, 0, false, null, null);
/**
* Download the offline / air-gapped licence file (.lic) for the team, when the paid offline
* add-on was purchased. 404 when there's no licence or the add-on wasn't taken — we don't leak
* that a licence exists to a team without the add-on.
* Download the offline / air-gapped licence file (.lic) for the team — available for an
* air-gapped deployment from the trial licence onward. 404 when there's no licence yet or the
* deployment isn't air-gapped, so we don't leak that a licence exists.
*/
@GetMapping("/license/file")
@PreAuthorize("isAuthenticated()")
@@ -193,10 +241,15 @@ public class ProcurementController {
@PostMapping("/trial/start")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<SnapshotResponse> startTrial(Authentication auth) {
public ResponseEntity<SnapshotResponse> startTrial(
@RequestBody(required = false) StartTrialRequest request, Authentication auth) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId), true));
// Body is optional so an older client (no setup step) still starts a cloud trial.
String deployment = request != null ? request.deployment() : null;
int seats = request != null ? request.users() : 0;
return ResponseEntity.ok(
toSnapshot(procurement.startTrial(teamId, deployment, seats), true));
}
@PostMapping("/trial/extend")
@@ -218,9 +271,7 @@ public class ProcurementController {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return ResponseEntity.ok(
toQuote(
procurement.buildQuote(
teamId, request.toConfig(), request.businessName())));
toQuote(procurement.buildQuote(teamId, request.toConfig(), request.toDetails())));
}
// Issue + accept are Supabase edge functions (they own Stripe): issue-procurement-quote turns a
@@ -323,6 +374,8 @@ public class ProcurementController {
return new SnapshotResponse(
deal.getDealId(),
deal.getStage(),
deal.getDeployment(),
deal.getSeats(),
str(deal.getTrialStartedAt()),
str(deal.getTrialEndsAt()),
deal.getTrialExtensionsUsed(),
@@ -339,23 +392,40 @@ public class ProcurementController {
q.getCurrency(),
q.getAnnualNetMinor(),
q.getTcvMinor(),
// Prefer the renewal locked at quote time; fall back to a live projection for
// quotes
// priced before the column existed.
q.getRenewalAnnualMinor() > 0
? q.getRenewalAnnualMinor()
: pricing.renewalAnnualMinor(q.getAnnualNetMinor()),
pricing.cpiRatePct(),
parseLineItems(q.getLineItemsJson()),
q.getValidUntil() == null ? null : q.getValidUntil().toString(),
q.getStripeQuoteId(),
q.getStripeInvoiceUrl(),
q.getStripeInvoicePdf(),
new QuoteConfigEcho(
q.getVolume(),
0,
q.getIntensity(),
q.getSizeMult(),
q.getDeployment(),
q.getTermYears(),
q.getServiceLevel(),
q.isIndemnification(),
q.isTraining(),
q.isQbr(),
q.isOfflineLicense(),
q.getCurrency(),
q.getBusinessName()));
q.getBusinessName(),
q.getContactName(),
q.getContactEmail(),
q.getAddressLine1(),
q.getAddressLine2(),
q.getCity(),
q.getRegion(),
q.getPostalCode(),
q.getPoNumber(),
q.getTaxId()));
}
private List<QuoteLineItem> parseLineItems(String json) {
@@ -51,6 +51,15 @@ public class ProcurementDeal implements Serializable {
@Column(name = "stage", nullable = false, length = 32)
private String stage = STAGE_TRIAL;
// Deployment target + seat count captured at trial start (the setup step); they seed the quote
// builder so it opens on the buyer's real environment. The quote remains the commercial source
// of truth — these are just the starting point, editable when the quote is built.
@Column(name = "deployment", nullable = false, length = 16)
private String deployment = "cloud";
@Column(name = "seats", nullable = false)
private int seats;
@Column(name = "trial_started_at")
private LocalDateTime trialStartedAt;
@@ -66,6 +66,10 @@ public class ProcurementQuote implements Serializable {
@Column(name = "intensity", nullable = false)
private int intensity = 4;
/** File-size tier multiplier on the rate (D93): Compact 1.0, Standard 1.4, Heavy 2.4. */
@Column(name = "size_mult", nullable = false)
private double sizeMult = 1.0;
@Column(name = "deployment", length = 24)
private String deployment;
@@ -84,15 +88,17 @@ public class ProcurementQuote implements Serializable {
@Column(name = "qbr", nullable = false)
private boolean qbr;
@Column(name = "offline_license", nullable = false)
private boolean offlineLicense;
@Column(name = "annual_net_minor", nullable = false)
private long annualNetMinor;
@Column(name = "tcv_minor", nullable = false)
private long tcvMinor;
// First post-term renewal fee (annual net + one CPI step), locked at quote time so the buyer's
// quoted renewal doesn't drift if the rate card changes later.
@Column(name = "renewal_annual_minor", nullable = false)
private long renewalAnnualMinor;
@Column(name = "line_items", columnDefinition = "text")
private String lineItemsJson;
@@ -105,10 +111,47 @@ public class ProcurementQuote implements Serializable {
@Column(name = "stripe_invoice_url", columnDefinition = "text")
private String stripeInvoiceUrl;
// Direct PDF link for that first invoice (Stripe invoice_pdf), set at accept alongside the URL;
// persisted so the portal's download button works after a reload, not just in the accept
// response.
@Column(name = "stripe_invoice_pdf", columnDefinition = "text")
private String stripeInvoicePdf;
// Buyer's company name (shown on the quote/agreement); echoed back so an edit remembers it.
@Column(name = "business_name", length = 255)
private String businessName;
// Buyer/AP details captured on the quote's "Your details" step. All optional — they never gate
// quote generation; they flow onto the Stripe customer (name + bill-to address) and the invoice
// (PO number + tax id as invoice custom fields), and seed the builder on a re-edit. Country and
// currency are intentionally out of scope for now.
@Column(name = "contact_name", length = 255)
private String contactName;
@Column(name = "contact_email", length = 255)
private String contactEmail;
@Column(name = "address_line1", length = 255)
private String addressLine1;
@Column(name = "address_line2", length = 255)
private String addressLine2;
@Column(name = "city", length = 128)
private String city;
@Column(name = "region", length = 128)
private String region;
@Column(name = "postal_code", length = 32)
private String postalCode;
@Column(name = "po_number", length = 128)
private String poNumber;
@Column(name = "tax_id", length = 64)
private String taxId;
@Column(name = "valid_until")
private LocalDate validUntil;
@@ -0,0 +1,21 @@
package stirling.software.saas.procurement.model;
/**
* Buyer / AP details captured on the quote's "Your details" step: the company and signatory
* contact, a billing address, and a PO number / tax id for the invoice. These are not pricing
* inputs (they never touch {@link stirling.software.saas.procurement.pricing.QuoteConfig}); they
* ride alongside the priced config so the quote can be re-seeded on an edit and the fields can flow
* onto the Stripe customer and invoice. All fields are optional. Country and currency are out of
* scope for now.
*/
public record QuoteDetails(
String businessName,
String contactName,
String contactEmail,
String addressLine1,
String addressLine2,
String city,
String region,
String postalCode,
String poNumber,
String taxId) {}
@@ -21,7 +21,8 @@ public record PricingRates(
long selfHostDeployMinor, // flat: self-hosted deployment
long airgapDeployMinor, // flat: air-gapped deployment
long qbrAnnualMinor, // flat: quarterly business reviews
long trainingOneTimeMinor) { // one-time: onboarding & training
long trainingOneTimeMinor, // one-time: onboarding & training
double cpiEscalator) { // fixed CPI uplift on the annual fee at each post-term renewal
public static PricingRates defaults() {
return new PricingRates(
@@ -34,7 +35,8 @@ public record PricingRates(
1_200_000, // self-hosted $12,000 / yr
3_600_000, // air-gapped $36,000 / yr
800_000, // QBRs $8,000 / yr
750_000); // onboarding & training $7,500 one-time
750_000, // onboarding & training $7,500 one-time
0.03); // 3% CPI escalator per renewal (committed term stays flat)
}
public double termDiscount(int termYears) {
@@ -61,6 +61,11 @@ public class ProcurementPricingService {
* (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2))
: 0.0;
double rate = Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc));
// File-size multiplier (D93): larger, image-heavy PDFs cost more OCR/compute/storage. Folds
// into the per-run rate after the floor, so it flows through the meter, TCV and renewal.
// QuoteConfig has already snapped it to a known tier, so a tampered request can't sneak a
// cheaper factor in.
rate *= cfg.sizeMult();
double termDisc = rates.termDiscount(cfg.termYears());
// The meter is a whole-dollar figure (the quote reads in dollars), then minor units.
@@ -82,6 +87,7 @@ public class ProcurementPricingService {
long annualNet = meterNetMinor + support + deploy + indemnity + qbr;
long tcv = annualNet * cfg.termYears() + training;
long renewalAnnual = renewalAnnualMinor(annualNet, rates);
double effectivePerPdf = rate * intensity; // quotes speak per-PDF-at-posture, never per-run
@@ -151,7 +157,29 @@ public class ProcurementPricingService {
QuoteLineItem.Kind.ONE_TIME,
training));
}
return new QuoteBreakdown(lines, annualNet, tcv, cfg.currency());
return new QuoteBreakdown(lines, annualNet, tcv, renewalAnnual, cfg.currency());
}
/** The default CPI escalator (fraction) applied to the annual fee on each post-term renewal. */
public double cpiEscalator() {
return PricingRates.defaults().cpiEscalator();
}
/** The CPI escalator as a whole-percent figure for buyer-facing copy (3% → 3). */
public int cpiRatePct() {
return (int) Math.round(cpiEscalator() * 100.0);
}
/**
* The escalated annual fee at the first renewal: the committed annual plus one CPI step. Used
* both when pricing a fresh quote and when echoing a stored one, so the two always agree.
*/
public long renewalAnnualMinor(long annualNetMinor) {
return renewalAnnualMinor(annualNetMinor, PricingRates.defaults());
}
private static long renewalAnnualMinor(long annualNetMinor, PricingRates rates) {
return Math.round(annualNetMinor * (1.0 + rates.cpiEscalator()));
}
private static long deployFeeMinor(String deployment, PricingRates rates) {
@@ -3,10 +3,15 @@ package stirling.software.saas.procurement.pricing;
import java.util.List;
/**
* The priced result of a {@link QuoteConfig}: the itemised lines plus the two headline figures the
* The priced result of a {@link QuoteConfig}: the itemised lines plus the headline figures the
* order form and Stripe checkout are built from. {@code annualNetMinor} is the recurring annual fee
* after the multi-year discount; {@code tcvMinor} is total contract value across the term including
* one-time fees. Minor units (cents).
* after the multi-year discount; {@code tcvMinor} is total contract value across the committed term
* including one-time fees; {@code renewalAnnualNetMinor} is the annual fee at the first post-term
* renewal after the fixed CPI escalator (the committed term itself is flat). Minor units (cents).
*/
public record QuoteBreakdown(
List<QuoteLineItem> lineItems, long annualNetMinor, long tcvMinor, String currency) {}
List<QuoteLineItem> lineItems,
long annualNetMinor,
long tcvMinor,
long renewalAnnualNetMinor,
String currency) {}
@@ -10,23 +10,35 @@ public record QuoteConfig(
long volume, // committed PDFs per year
int users, // seats (drives the volume auto-estimate when the buyer hasn't overridden)
int intensity, // policy posture: runs per PDF — Essentials 2, Governed 4, Regulated 7
double sizeMult, // file-size tier multiplier on the rate — Compact 1.0 / Standard 1.4 /
// Heavy 2.4
String deployment, // cloud | selfhost | airgap (priced flat; inherited from the trial)
int termYears, // 1..5
String serviceLevel, // standard | priority (both included) | dedicated (flat SE/CSM fee)
boolean indemnification,
boolean training,
boolean qbr,
boolean offlineLicense, // offline .lic availability (gates download; no longer priced here)
String currency) { // USD only for now
/** Default posture when none is chosen — Governed (x4), per the pricing alignment decision. */
public static final int DEFAULT_INTENSITY = 4;
/** Known file-size tier multipliers (D93): Compact 1.0, Standard 1.4, Heavy 2.4. */
private static final double[] SIZE_MULTS = {1.0, 1.4, 2.4};
public QuoteConfig {
if (termYears < 1) termYears = 1;
if (termYears > 5) termYears = 5;
if (intensity < 1) intensity = DEFAULT_INTENSITY;
if (serviceLevel == null || serviceLevel.isBlank()) serviceLevel = "standard";
if (currency == null || currency.isBlank()) currency = "USD";
// Snap the file-size multiplier to a known tier so a tampered request can't invent a
// cheaper
// one; absent (0.0) or unknown falls back to 1.0 (no uplift).
double snapped = 1.0;
for (double s : SIZE_MULTS) {
if (Math.abs(s - sizeMult) < 1e-9) snapped = s;
}
sizeMult = snapped;
}
}
@@ -24,6 +24,7 @@ import stirling.software.saas.procurement.license.EnterpriseLicenseService;
import stirling.software.saas.procurement.license.LicenseEntitlements;
import stirling.software.saas.procurement.model.ProcurementDeal;
import stirling.software.saas.procurement.model.ProcurementQuote;
import stirling.software.saas.procurement.model.QuoteDetails;
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
import stirling.software.saas.procurement.pricing.QuoteBreakdown;
import stirling.software.saas.procurement.pricing.QuoteConfig;
@@ -96,28 +97,46 @@ public class ProcurementService {
/**
* Start (or restart) the free trial for a team: issue a mock trial licence and stamp the trial
* window on the deal. No Stripe: a no-card trial has no subscription; the entitlement is the
* Keygen licence, and the deal row is the journey state.
* Keygen licence, and the deal row is the journey state. The buyer's chosen deployment target
* ({@code cloud}/{@code selfhost}/{@code airgap}) and seat count are captured here so the quote
* builder opens seeded to their environment; both are still editable when the quote is built.
*/
@Transactional
public ProcurementDeal startTrial(Long teamId) {
public ProcurementDeal startTrial(Long teamId, String deployment, int seats) {
ProcurementDeal deal =
dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
LocalDateTime now = LocalDateTime.now();
LocalDateTime ends = now.plusDays(config.getTrialDurationDays());
deal.setStage(ProcurementDeal.STAGE_TRIAL);
deal.setDeployment(normalizeDeployment(deployment));
deal.setSeats(Math.max(0, seats));
deal.setTrialStartedAt(now);
deal.setTrialEndsAt(ends);
deal.setTrialExtensionsUsed(0);
deal.setLicenseRef(licenses.issueTrialLicense(teamId, leaderEmail(teamId), ends));
deal = dealRepo.save(deal);
log.info(
"[procurement] trial started team={} deal={} ends={}",
"[procurement] trial started team={} deal={} deployment={} seats={} ends={}",
teamId,
deal.getDealId(),
deal.getDeployment(),
deal.getSeats(),
ends);
return deal;
}
/**
* Constrain a caller-supplied deployment to the known set; anything else falls back to cloud.
*/
private static String normalizeDeployment(String deployment) {
if (deployment == null) return "cloud";
String d = deployment.trim().toLowerCase(Locale.ROOT);
return switch (d) {
case "selfhost", "airgap", "cloud" -> d;
default -> "cloud";
};
}
/** Extend the current trial by the configured increment, up to the cap. */
@Transactional
public ProcurementDeal extendTrial(Long teamId) {
@@ -145,7 +164,7 @@ public class ProcurementService {
/** Price a quote config server-side and persist it as a draft against the team's deal. */
@Transactional
public ProcurementQuote buildQuote(Long teamId, QuoteConfig cfg, String businessName) {
public ProcurementQuote buildQuote(Long teamId, QuoteConfig cfg, QuoteDetails details) {
ProcurementDeal deal =
dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
if (ProcurementDeal.STAGE_LIVE.equals(deal.getStage())) {
@@ -168,16 +187,26 @@ public class ProcurementService {
quote.setVolume(cfg.volume());
quote.setSeats(cfg.users() > 0 ? cfg.users() : null);
quote.setIntensity(cfg.intensity());
quote.setSizeMult(cfg.sizeMult());
quote.setDeployment(cfg.deployment());
quote.setTermYears(cfg.termYears());
quote.setServiceLevel(cfg.serviceLevel());
quote.setIndemnification(cfg.indemnification());
quote.setTraining(cfg.training());
quote.setQbr(cfg.qbr());
quote.setOfflineLicense(cfg.offlineLicense());
quote.setBusinessName(businessName);
quote.setBusinessName(details.businessName());
quote.setContactName(details.contactName());
quote.setContactEmail(details.contactEmail());
quote.setAddressLine1(details.addressLine1());
quote.setAddressLine2(details.addressLine2());
quote.setCity(details.city());
quote.setRegion(details.region());
quote.setPostalCode(details.postalCode());
quote.setPoNumber(details.poNumber());
quote.setTaxId(details.taxId());
quote.setAnnualNetMinor(breakdown.annualNetMinor());
quote.setTcvMinor(breakdown.tcvMinor());
quote.setRenewalAnnualMinor(breakdown.renewalAnnualNetMinor());
quote.setLineItemsJson(writeLineItems(breakdown));
quote.setValidUntil(LocalDate.now().plusDays(30));
quote = quoteRepo.save(quote);
@@ -275,7 +304,7 @@ public class ProcurementService {
q != null && q.isIndemnification(),
q != null && q.isTraining(),
q != null && q.isQbr(),
q != null && q.isOfflineLicense(),
"airgap".equalsIgnoreCase(deployment), // offline .lic = air-gapped deploy
deal.getDealId(),
deal.getSubscriptionId());
return licenses.issueAnnualLicense(
@@ -287,30 +316,26 @@ public class ProcurementService {
}
/**
* Check out the offline/air-gapped licence file for a team, when the offline add-on was
* purchased. Requires an issued licence on the deal and the accepted quote to carry the offline
* add-on; returns empty otherwise (so the controller can 404 rather than leak that a licence
* exists). The certificate is generated on demand by Keygen and never stored.
* Check out the offline/air-gapped licence file (.lic) for a team. Available for an air-gapped
* deployment (chosen at trial setup) from the trial licence onward — cloud/self-hosted verify
* online against Keygen and don't get a file. Returns empty when there's no licence yet or the
* deployment isn't air-gapped, so the controller can 404 rather than leak that a licence
* exists. The certificate is generated on demand by Keygen (from whatever licence the deal
* currently holds — trial or committed annual) and never stored.
*
* <p>By design a team can self-select air-gapped at trial and download a real signed .lic
* before paying — that's bounded: the trial licence carries {@code expiry = trialEndsAt}, so
* the file the verifier accepts self-expires at trial end. The buyer must re-download after
* provisioning to get the committed-term file (the portal warns about this).
*/
@Transactional(readOnly = true)
public Optional<String> offlineLicenseFile(Long teamId) {
ProcurementDeal deal = dealRepo.findByTeamId(teamId).orElse(null);
if (deal == null || deal.getLicenseRef() == null) return Optional.empty();
if (!hasOfflineAddOn(deal)) return Optional.empty();
if (!"airgap".equalsIgnoreCase(deal.getDeployment())) return Optional.empty();
return Optional.of(licenses.checkOutLicenseFile(deal.getLicenseRef()));
}
/**
* Whether the deal's <b>accepted</b> quote carries the paid offline-licence add-on. Gated on
* the accepted quote (not the latest) so merely toggling the add-on on an unaccepted draft
* can't unlock the offline file — it's only available once the add-on has actually been bought.
*/
private boolean hasOfflineAddOn(ProcurementDeal deal) {
if (deal.getAcceptedQuoteId() == null) return false;
ProcurementQuote quote = quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null);
return quote != null && quote.isOfflineLicense();
}
/**
* Reset a team's procurement: delete the deal (quotes + activity cascade). For
* re-demos/testing.
@@ -0,0 +1,7 @@
-- Direct PDF link for a procurement quote's first invoice (Stripe invoice_pdf), stored at accept
-- alongside stripe_invoice_url so the portal's "Download invoice" button survives a reload instead
-- of relying on the transient accept response. Written by the accept edge function via the
-- procurement_set_quote_accepted RPC; read by the Java backend via JPA. A Supabase twin mirrors it.
ALTER TABLE stirling_pdf.procurement_quote
ADD COLUMN IF NOT EXISTS stripe_invoice_pdf TEXT;
@@ -0,0 +1,10 @@
-- Deployment target + seat count captured at the trial-start step (the setup dialog the demo shows
-- before a trial begins), stored on the deal so the quote builder seeds from the buyer's real
-- environment instead of a hardcoded default. deployment: cloud | selfhost | airgap. seats: 0 =
-- unspecified. Written and read by the Java backend via JPA. A Supabase twin migration mirrors it.
ALTER TABLE stirling_pdf.procurement_deal
ADD COLUMN IF NOT EXISTS deployment VARCHAR(16) NOT NULL DEFAULT 'cloud';
ALTER TABLE stirling_pdf.procurement_deal
ADD COLUMN IF NOT EXISTS seats INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,7 @@
-- Persist the first post-term renewal fee (annual net + one CPI step) computed at quote time, so the
-- figure shown to the buyer is locked to what they were quoted rather than recomputed from the
-- current rate card on every read. Minor units. Written and read by the Java backend via JPA; a
-- Supabase twin migration mirrors it.
ALTER TABLE stirling_pdf.procurement_quote
ADD COLUMN IF NOT EXISTS renewal_annual_minor BIGINT NOT NULL DEFAULT 0;
@@ -0,0 +1,7 @@
-- File-size tier multiplier on the quote (D93): larger, image-heavy PDFs cost more, so the buyer
-- picks a size tier (Compact 1.0 / Standard 1.4 / Heavy 2.4) that scales the per-run rate. Persisted
-- so the quote re-prices and re-seeds the builder consistently. Defaults to 1.0 (no uplift) for rows
-- that predate the column. Written and read by the Java backend via JPA. A Supabase twin mirrors it.
ALTER TABLE stirling_pdf.procurement_quote
ADD COLUMN IF NOT EXISTS size_mult DOUBLE PRECISION NOT NULL DEFAULT 1.0;
@@ -0,0 +1,17 @@
-- Buyer / AP details captured on the quote's "Your details" step: the signatory contact and a
-- billing address, plus a PO number and tax id for the invoice. All optional (never gate quote
-- generation). Persisted so the quote re-seeds the builder on a re-edit and so the issue edge
-- function can put them on the Stripe customer (name + bill-to address) and invoice (PO / tax id
-- as custom fields). Country and currency are intentionally out of scope for now. Written and read
-- by the Java backend via JPA. A Supabase twin mirrors these columns.
ALTER TABLE stirling_pdf.procurement_quote
ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255),
ADD COLUMN IF NOT EXISTS address_line1 VARCHAR(255),
ADD COLUMN IF NOT EXISTS address_line2 VARCHAR(255),
ADD COLUMN IF NOT EXISTS city VARCHAR(128),
ADD COLUMN IF NOT EXISTS region VARCHAR(128),
ADD COLUMN IF NOT EXISTS postal_code VARCHAR(32),
ADD COLUMN IF NOT EXISTS po_number VARCHAR(128),
ADD COLUMN IF NOT EXISTS tax_id VARCHAR(64);
@@ -18,8 +18,13 @@ class ProcurementPricingServiceTest {
private static QuoteConfig cfg(
long volume, int intensity, String deployment, int term, String sla) {
return cfgSize(volume, intensity, deployment, term, sla, 1.0);
}
private static QuoteConfig cfgSize(
long volume, int intensity, String deployment, int term, String sla, double sizeMult) {
return new QuoteConfig(
volume, 0, intensity, deployment, term, sla, false, false, false, false, "USD");
volume, 0, intensity, sizeMult, deployment, term, sla, false, false, false, "USD");
}
@Test
@@ -30,6 +35,7 @@ class ProcurementPricingServiceTest {
assertThat(q.annualNetMinor()).isEqualTo(175_200_000L); // $1,752,000
assertThat(q.tcvMinor()).isEqualTo(525_600_000L); // $5,256,000
assertThat(q.renewalAnnualNetMinor()).isEqualTo(180_456_000L); // $1,752,000 + 3% CPI
assertThat(lineAmount(q, "support")).isEqualTo(3_000_000L); // dedicated SE/CSM $30K
assertThat(lineAmount(q, "deployment")).isEqualTo(1_200_000L); // self-hosted $12K
}
@@ -46,6 +52,41 @@ class ProcurementPricingServiceTest {
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("support"));
}
@Test
void renewalAppliesCpiEscalatorAfterAFlatTerm() {
// The committed term is flat (TCV = annual × years, asserted above). The 3% CPI escalator
// describes only the first post-term renewal: annual + one 3% step. It never touches TCV.
QuoteBreakdown q = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard"));
assertThat(q.renewalAnnualNetMinor())
.isEqualTo(Math.round(q.annualNetMinor() * 1.03)); // 16,527,800 → 17,023,634
assertThat(q.tcvMinor()).isEqualTo(q.annualNetMinor() * 3); // renewal is outside the TCV
assertThat(pricing.cpiRatePct()).isEqualTo(3);
assertThat(pricing.renewalAnnualMinor(q.annualNetMinor()))
.isEqualTo(q.renewalAnnualNetMinor()); // stored-quote echo agrees with pricing
}
@Test
void fileSizeTierMultipliesTheMeter() {
// D93: the size tier scales the per-run rate, so the meter (hence annual/TCV/renewal) grows
// while flat fees stay put. Compact (1.0) is the anchor; Standard is ×1.4, Heavy ×2.4.
long compact =
pricing.price(cfgSize(6_000_000, 4, "cloud", 3, "standard", 1.0)).annualNetMinor();
long standard =
pricing.price(cfgSize(6_000_000, 4, "cloud", 3, "standard", 1.4)).annualNetMinor();
long heavy =
pricing.price(cfgSize(6_000_000, 4, "cloud", 3, "standard", 2.4)).annualNetMinor();
assertThat(compact).isEqualTo(16_527_800L); // == the Northwind anchor (size 1.0)
assertThat(standard).isEqualTo(23_138_900L); // rate ×1.4
assertThat(compact).isLessThan(standard);
assertThat(standard).isLessThan(heavy);
// An unknown/tampered multiplier snaps back to 1.0 (no cheaper factor sneaks through).
assertThat(
pricing.price(cfgSize(6_000_000, 4, "cloud", 3, "standard", 0.3))
.annualNetMinor())
.isEqualTo(compact);
}
@Test
void rateFloorsAtHalfACent() {
// 100M × Regulated(×7) = 700M runs — deep past the knee, so the per-run rate is pinned to
@@ -104,7 +145,7 @@ class ProcurementPricingServiceTest {
long base = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
QuoteConfig c =
new QuoteConfig(
6_000_000, 0, 4, "cloud", 3, "standard", true, false, false, false, "USD");
6_000_000, 0, 4, 1.0, "cloud", 3, "standard", true, false, false, "USD");
QuoteBreakdown q = pricing.price(c);
assertThat(lineAmount(q, "indemnification")).isEqualTo(Math.round(base * 0.05));
}
@@ -113,7 +154,7 @@ class ProcurementPricingServiceTest {
void trainingIsOneTimeOutsideTheAnnual() {
QuoteConfig withTraining =
new QuoteConfig(
6_000_000, 0, 4, "cloud", 3, "standard", false, true, false, false, "USD");
6_000_000, 0, 4, 1.0, "cloud", 3, "standard", false, true, false, "USD");
QuoteBreakdown q = pricing.price(withTraining);
long baseAnnual = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
@@ -5937,59 +5937,12 @@ keyDescription = "Paste the license key from your email"
success = "License Activated!"
successMessage = "Your license has been successfully activated. You can now close this window."
[policies]
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
deleteConfirmTitle = "Delete {{label}} policy?"
[policies.activity]
enforced = "enforced"
enforcing = "Enforcing..."
failed = "Enforcement failed"
outputsUnavailable = "Policy outputs are no longer available to download."
partialOutputsUnavailable = "Some policy outputs are no longer available to download."
retrying = "Busy, retrying..."
runNotFound = "The enforcement run could no longer be found."
step = "step {{current}}/{{total}}"
timedOut = "Enforcement timed out before the run could finish."
[policies.catalog]
compliance = "Compliance"
ingestion = "Ingestion"
retention = "Retention"
routing = "Routing"
security = "Security"
[policies.detail]
close = "Close"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organization. Contact a team leader to change this policy."
noActivityDescription = "Documents will appear here once this policy runs."
noActivityTitle = "No activity yet"
onEveryUpload = "On every upload"
originalsNote = "Originals stay untouched • Enforced version saved alongside"
pause = "Pause"
recentActivity = "Recent Activity"
resume = "Resume"
retry = "Retry"
showLess = "Show less"
showMore = "Show more"
statActive = "Active"
statDataProcessed = "Data processed"
statDocsEnforced = "Docs enforced"
statusActive = "Active"
statusPaused = "Paused"
[policies.docType]
Contracts = "Contracts"
"Financial reports" = "Financial reports"
"HR records" = "HR records"
Insurance = "Insurance"
Invoices = "Invoices"
"Legal filings" = "Legal filings"
"Medical / PHI" = "Medical / PHI"
"Tax documents" = "Tax documents"
[policies.enforcement]
applying = "Applying {{names}}"
applyingProgress = "Applying {{names}} ({{done}} of {{total}})"
@@ -5999,32 +5952,10 @@ failureBody = "{{failures}} of {{total}} file(s) couldn't be processed and were
failureTitle = "Exported without full enforcement"
printPolicyAppliedBody = "This PDF was updated to meet a policy. Review the changes, then print again."
printPolicyAppliedTitle = "Policy applied before printing"
queued = "+{{count}} queued"
successTitle = "{{names}} applied"
summaryMore = "{{first}}, {{second}} and {{more}} more"
summaryTwo = "{{first}} and {{second}}"
[policies.enforcement.triggerVerb]
convert = "Enforcing before convert"
default = "Enforcing"
export = "Enforcing before export"
input = "Enforcing on import"
print = "Enforcing before print"
[policies.field]
accessLog = "Access log"
archiveAfter = "Archive after"
auditTrail = "Audit trail"
belowThreshold = "Below threshold"
destination = "Destination"
frameworks = "Frameworks"
immutableHold = "Immutable hold"
keepFor = "Keep for"
minConfidence = "Min confidence"
notify = "Notify on route"
onViolation = "When non-compliant"
webhookUrl = "Webhook URL"
[policies.fieldOption.archiveAfter]
"1 year" = "1 year"
"30 days" = "30 days"
@@ -6070,9 +6001,6 @@ Indefinite = "Indefinite"
"Flag for review" = "Flag for review"
"Quarantine document" = "Quarantine document"
[policies.fields]
selectedCount = "{{count}} selected"
[policies.labels]
add = "Add"
addPlaceholder = "Add a label…"
@@ -6111,90 +6039,6 @@ placeholder = "Select PII types"
routing = "US routing numbers (ABA)"
ssn = "Social Security numbers"
[policies.settings]
noneExport = "No policies currently run on export."
noneUpload = "No policies currently run on upload."
onExport = "On export"
onUpload = "On upload"
reorderHandle = "Drag to reorder"
runOrderDesc = "When more than one policy runs on the same trigger, they run in this order — each on the previous policy's output. Drag to reorder."
title = "Policy settings"
[policies.sidebar]
activeCount = "{{count}} active"
infoTooltip = "A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps."
loading = "Loading…"
optionsAriaLabel = "Policy options"
policySettings = "Policy settings"
railAriaLabel = "{{label}} policy — {{status}}"
railSuffixActive = " (Active)"
railSuffixPaused = " (Paused)"
retryFailed = "Retry failed policies ({{count}})"
rowProgress = "{{completed}} of {{total}} files processed"
setUp = "Set up"
title = "Policies"
upgradeToEnterprise = "Upgrade to enterprise"
whatIsPolicy = "What is a policy?"
[policies.status]
active = "Active"
paused = "Paused"
setup = "Set up"
[policies.toolConfig]
enableAriaLabel = "Enable {{tool}}"
infoAriaLabel = "What does {{tool}} do?"
[policies.toolConfig.info]
redact = "Automatically finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read in the document."
sanitize = "Removes hidden JavaScript from the file, so nothing can run automatically when someone opens it."
watermark = "Stamps a visible mark (e.g. \"Confidential\") across every page."
[policies.wizard]
allDocTypesDescription = "Enable the Classification policy to filter by document type."
allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Close"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
editTitle = "Edit {{label}} Policy"
enablePolicy = "Enable Policy"
filenameAutoNumber = "Auto-number"
filenamePositionAria = "Filename position"
filenamePrefix = "Prefix"
filenameSuffix = "Suffix"
filenameTextAria = "Filename text"
filenameTextPlaceholder = "Text to add (optional)"
lockedDescription = "Contact a team leader to change this policy."
lockedTitle = "Managed by your organization"
maxRetriesLabel = "Max retries"
noToolsError = "Add at least one configured tool to the workflow first."
outputAsLabel = "Output as"
outputFilenameSubhead = "Output filename"
outputModeAria = "Output mode"
outputNewFile = "New file"
outputNewVersion = "New version"
outputRetriesLabel = "Output & retries"
outputSubhead = "Output"
retryDelayAria = "Retry delay minutes"
retryDelayLabel = "Retry delay (min)"
runOnExport = "Export"
runOnLabel = "Run on"
runOnSubhead = "Run on"
runOnUpload = "Upload"
saveChanges = "Save Changes"
saveError = "Couldn't save the policy. Please try again."
setupClassification = "Set up Classification"
setupTitle = "Set up {{label}} Policy"
sourcesDesc = "Choose where this policy runs and which document types it applies to."
sourcesLabel = "Sources"
stepOf = "Step {{step}} of {{total}}"
toolChainDesc = "Configure the tools this policy runs on each document."
typesSelected = "{{count}} types selected"
[policy]
badgeEnforcing = "{{name}} enforcing..."
badgeRan = "{{name}} policy ran on this file"
@@ -7403,6 +7247,11 @@ 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"
sources = "Sources"
@@ -7422,6 +7271,7 @@ confirm = "Delete"
title = "Delete pipeline?"
[portal.pipelines.detail]
clearHistory = "Clear history"
delete = "Delete pipeline"
run = "Run now"
@@ -7439,12 +7289,19 @@ total = "Pipelines"
[portal.pipelines.output]
folder = "Write to folder"
inline = "Return files"
s3 = "Write to Amazon S3"
[portal.pipelines.run]
allProcessed_one = "Nothing to run: the source's {{count}} document has already been processed."
allProcessed_other = "Nothing to run: all {{count}} documents in the sources have already been processed."
completed_one = "Run completed."
completed_other = "All {{count}} runs completed."
empty = "Nothing to run: the sources had no documents to process."
failed = "Run failed: {{error}}"
historyCleared = "History cleared. The next run reprocesses everything currently in the sources."
inFlight = "Nothing new to run: documents are still being processed from an earlier run."
parked_one = "Nothing to run: {{count}} document failed previously and is parked. Fix the cause, then clear history to retry it."
parked_other = "Nothing to run: {{count}} documents failed previously and are parked. Fix the cause, then clear history to retry them."
running = "Run started; still in progress."
timeout = "Run is taking longer than expected; it may still finish in the background."
@@ -7728,7 +7585,6 @@ managePlan = "Manage plan"
volumeSuffix = "PDFs processed · last 30 days"
[portal.procurement]
reset = "Reset procurement (demo)"
subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place."
title = "Procurement"
@@ -7748,23 +7604,42 @@ title = "Review your enterprise agreement"
[portal.procurement.builder]
addons = "Add-ons"
addressLine1 = "Address line 1"
addressLine1Placeholder = "500 Howard St"
addressLine2 = "Address line 2"
addressLine2Placeholder = "Suite, floor, building"
back = "Back"
businessName = "Business name"
businessNamePlaceholder = "Your company"
city = "City"
cityPlaceholder = "San Francisco"
contactEmail = "Contact email"
contactEmailPlaceholder = "jane@acme.com"
contactName = "Contact name"
contactNamePlaceholder = "Jane Doe"
continue = "Continue"
country = "Country"
countryEuro = "Eurozone (EUR €)"
countryUK = "United Kingdom (GBP £)"
countryUS = "United States (USD $)"
eula = "I have read and agree to the Stirling Enterprise EULA. It governs the agreement generated from this quote."
generate = "Generate quote"
included = "Included"
indemnification = "IP indemnification"
indemnificationSub = "We defend qualifying IP claims, per the EULA"
offlineLicense = "Offline / air-gapped licence"
offlineLicenseSub = "A downloadable licence file for an air-gapped self-hosted instance"
pdfSize = "PDF size"
poNumber = "PO number"
poNumberPlaceholder = "Optional"
postalCode = "Postal code"
postalCodePlaceholder = "94105"
posture = "Governance"
posture_count = "~{{count}} policies"
posture_essentials = "Essentials"
posture_essentialsSub = "Classification + Sharing — the defaults"
posture_governed = "Governed"
posture_governedSub = "Adds Security and Routing"
posture_regulated = "Regulated"
posture_regulatedSub = "Every category — Compliance, Retention, Ingestion"
qbr = "Quarterly business reviews"
qbrSub = "Your SE reviews usage and roadmap each quarter"
region = "State / region"
regionPlaceholder = "California"
running = "{{annual}} / yr · {{years}}-yr {{tcv}}"
s1Sub = "Your team, and the PDFs you expect to run each year."
# Step 1 — volume
@@ -7776,13 +7651,21 @@ s3Sub = "For the quote and the agreement it generates."
# Step 3 — details
s3Title = "Your details"
serviceLevel = "Service level"
size_compact = "Compact"
size_compactSub = "Mostly text, under 1 MB"
size_heavy = "Heavy"
size_heavySub = "Scanned or image-heavy, 5 MB+"
size_standard = "Standard"
size_standardSub = "Mixed text and images, 1 to 5 MB"
slDedicated = "Dedicated"
slDedicatedSub = "4 business hours · dedicated account manager · +30%"
slDedicatedSub = "4 business hours · dedicated SE / CSM · +$30,000/yr"
slPriority = "Priority"
slPrioritySub = "Same business day · named CSM · +15%"
slPrioritySub = "Same business day · named CSM · included"
slStandard = "Standard"
slStandardSub = "Next business day · shared CSM · included"
stepOf = "Step {{n}} of {{total}}"
taxId = "VAT / Tax ID"
taxIdPlaceholder = "Optional"
term = "Term"
termDiscount = "{{pct}}% multi-year commitment discount applied"
title = "Build your quote"
@@ -7818,14 +7701,13 @@ title = "Something went wrong"
[portal.procurement.hero]
company = "Your enterprise deal"
ctaAgreement = "Review & sign agreement"
ctaLive = "You're live"
ctaPayment = "Add payment"
ctaQuote = "Review your quote"
ctaTrial = "Build your quote"
eyebrow = "Enterprise procurement"
inviteTeammates = "Invite teammates"
keyDocs = "Key documents"
licenseKey = "Licence key"
nextStep = "Next step: {{action}}"
notStarted = "Not started"
open = "Open procurement"
@@ -7873,56 +7755,6 @@ blurb = "Evaluate Stirling against your documents and workflows."
gatingAction = "Build your quote"
label = "Trial"
[portal.procurement.keyDocs]
oneTimeFee = " · one-time {{amount}}"
subtitle = "Everything for each stage of your rollout, in one place."
title = "Key documents"
[portal.procurement.keyDocs.docs.baa]
name = "Business Associate Agreement"
sub = "HIPAA · available on request"
[portal.procurement.keyDocs.docs.bankTransfer]
name = "Bank transfer instructions"
sub = "Wire details for your AP team"
[portal.procurement.keyDocs.docs.coi]
name = "Certificate of Insurance"
sub = "Cyber + E&O · current policy"
[portal.procurement.keyDocs.docs.formalQuote]
name = "Formal quote"
sub = "Built to your volume, term, and service level"
[portal.procurement.keyDocs.docs.msa]
name = "Master Services Agreement"
sub = "One signature - MSA, order form, EULA, and DPA combined"
[portal.procurement.keyDocs.docs.purchaseOrder]
name = "Purchase order"
sub = "Issuing a PO? Upload it and we invoice against it"
[portal.procurement.keyDocs.docs.securityReview]
name = "Custom security review"
sub = "We complete your questionnaire and join your review call"
[portal.procurement.keyDocs.docs.soc2]
name = "SOC 2 Type II report"
sub = "Audited · NDA-gated"
[portal.procurement.keyDocs.docs.w9]
name = "IRS Form W-9"
sub = "Stirling PDF Inc."
[portal.procurement.keyDocs.groups]
evaluation = "Supporting your evaluation"
yourDeal = "Your deal"
[portal.procurement.keyDocs.status]
action = "Action needed"
available = "Download"
request = "Request"
[portal.procurement.license]
copied = "Copied"
copy = "Copy key"
@@ -7930,6 +7762,9 @@ downloadError = "Could not generate the offline licence file just yet — please
downloadOffline = "Download offline licence (.lic)"
hint = "Paste this key into a self-hosted instance to activate it, or keep it for your records. Keep it safe."
label = "Your licence key"
subtitle = "Activate a self-hosted instance with this key, or keep it for your records."
title = "Your licence key"
trialFileHint = "This is your trial licence file. Once your agreement is in place, come back and download it again — unlike the online licence key, the .lic file won't update on its own."
[portal.procurement.link]
cta = "Link account"
@@ -7949,16 +7784,9 @@ talkToSales = "Talk to sales"
title = "The procurement track opens with Enterprise"
[portal.procurement.milestone]
accept = "Accept & continue"
description = "Download the PDF to share it with your team, come back to accept when you're ready, or make changes."
download = "Download PDF"
downloadError = "Could not download the quote PDF just yet — please try again in a moment."
edit = "Edit quote"
eyebrow = "Quote {{number}}"
perYear = " / yr"
preparedFor = "Prepared for {{company}}"
tcv = "{{value}} total contract value"
title = "Your quote is ready"
[portal.procurement.modal]
cancel = "Cancel"
@@ -7985,7 +7813,6 @@ uploadTitle = "Upload your purchase order"
[portal.procurement.payment]
description = "Your quote is accepted and your licence is already active — your team can start right away. Pay the first invoice when you're ready; you can pay or download it here, no email needed."
downloadInvoice = "Download invoice"
simulate = "Simulate payment received (demo)"
title = "Subscription created"
viewInvoice = "View & pay invoice"
@@ -7995,6 +7822,21 @@ fallbackLink = "Open scheduling in a new tab"
subtitle = "Your solutions engineer will walk your team through the rollout. Pick a time that suits you."
title = "Schedule a call"
[portal.procurement.setup]
airgap = "Air-gapped"
airgapSub = "Fully offline, isolated network. Includes a downloadable licence file."
cloud = "Cloud"
cloudSub = "Fully managed by Stirling. Nothing for you to run."
deployment = "Where will you run Stirling?"
seats = "Team size"
seatsHint = "Roughly how many people will use it. You can refine this when you build your quote."
seatsPlaceholder = "e.g. 250"
selfhost = "Self-hosted"
selfhostSub = "Run it in your own cloud or data centre."
start = "Start trial"
subtitle = "Tell us how you plan to run Stirling so we can tailor your trial and quote. No card required."
title = "Set up your trial"
[portal.procurement.status]
action = "Action needed"
available = "Available"
@@ -8153,6 +7995,42 @@ label = "Folder depth"
all = "Include subfolders"
top = "Top level only"
[portal.sources.types.s3]
description = "Pull documents from an Amazon S3 or S3-compatible bucket."
label = "Amazon S3"
[portal.sources.types.s3.fields.accessKeyId]
label = "Access key ID"
[portal.sources.types.s3.fields.bucket]
label = "Bucket"
placeholder = "my-company-inbox"
[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"
placeholder = "https://s3.example.com"
[portal.sources.types.s3.fields.mode]
helperText = "Consume removes each object from the bucket once every policy has processed it."
label = "Read mode"
[portal.sources.types.s3.fields.mode.options]
consume = "Consume: process each object once"
snapshot = "Snapshot: re-read the bucket every run"
[portal.sources.types.s3.fields.prefix]
helperText = "Only objects whose keys start with this prefix are processed."
label = "Key prefix"
placeholder = "incoming/"
[portal.sources.types.s3.fields.region]
label = "Region"
placeholder = "us-east-1"
[portal.sources.types.s3.fields.secretAccessKey]
label = "Secret access key"
[portal.sources.types.unknown]
label = "Source"
@@ -1,43 +0,0 @@
/**
* Core stubs for the right-rail Policies UI.
*
* The real implementations live in {@code proprietary/components/policies/PoliciesSidebar.tsx}
* and shadow these stubs via the {@code @app/*} alias cascade when the proprietary
* build is active. Core builds render nothing, so the right rail shows only the
* tool list unchanged.
*/
import type { ReactNode } from "react";
/** Whether the right rail should host the Policies section. False in core. */
export function usePoliciesEnabled(): boolean {
return false;
}
/** Whether the Policies list should appear for the current user. False in core. */
export function usePoliciesVisible(): boolean {
return false;
}
/**
* Whether a policy is open (its detail should take over the rail). Always false
* in core; proprietary bridges to the policy-selection store.
*/
export function usePolicyDetailActive(): boolean {
return false;
}
/** Collapsible policy list rendered above the Tools section. Null in core. */
export function PoliciesSection(_props: { leadingControl?: ReactNode } = {}) {
return null;
}
/** Open-policy detail/wizard/settings that replaces the tool area. Null in core. */
export function PolicyDetailTakeover() {
return null;
}
/** Collapsed-rail policy icons. Null in core; proprietary renders the rail. */
export function PoliciesCollapsedButton(_props: { onExpand: () => void }) {
return null;
}
@@ -0,0 +1,8 @@
/**
* Core stub — whether policy enforcement is active for this build. Gates
* mounting the headless PolicyAutoRunController. Always false in core; the
* proprietary and desktop builds shadow this via the {@code @app/*} alias.
*/
export function usePoliciesEnabled(): boolean {
return false;
}
@@ -5,14 +5,7 @@ import { useSidebarContext } from "@app/contexts/SidebarContext";
import { useIsMobile } from "@app/hooks/useIsMobile";
import ToolPanel from "@app/components/tools/ToolPanel";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import {
PoliciesCollapsedButton,
PoliciesSection,
PolicyDetailTakeover,
usePoliciesEnabled,
usePoliciesVisible,
usePolicyDetailActive,
} from "@app/components/policies/PoliciesSidebar";
import { usePoliciesEnabled } from "@app/components/policies/usePoliciesEnabled";
import { PolicyAutoRunController } from "@app/components/policies/PolicyAutoRunController";
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
import { useToolSections } from "@app/hooks/useToolSections";
@@ -66,8 +59,6 @@ export default function RightSidebar() {
} = useToolWorkflow();
const policiesEnabled = usePoliciesEnabled();
const policiesVisible = usePoliciesVisible();
const rawPolicyDetailActive = usePolicyDetailActive();
const fullscreenExpanded = useIsFullscreenExpanded();
const fullscreenGeometry = useToolPanelGeometry({
enabled: fullscreenExpanded,
@@ -100,39 +91,12 @@ export default function RightSidebar() {
});
};
// Opening a policy (e.g. from the collapsed rail) lands the rail in the clean
// default tool-picker view — the only view the policy takeover renders in — so
// it never collides with an open tool or the all-tools/search view.
const handleOpenPolicy = () => {
withViewTransition(() => {
if (readerMode) setReaderMode(false);
setLeftPanelView("toolPicker");
if (!sidebarsVisible) setSidebarsVisible(true);
setAllToolsView(false);
setSearchQuery("");
});
};
// The header shows [back] [search] when we have somewhere to go back to —
// i.e. the user is in a specific tool, or already in the all-tools/search view.
const inToolView = leftPanelView !== "toolPicker";
// Show X (close) button only when there's somewhere to go back to.
const showCloseButton = inToolView || allToolsView;
// Policies sit above the tool list in the default tool-picker view — but only
// when the current user actually has policies to see (see usePoliciesVisible),
// so regular users with none get the plain tool picker with no empty block.
const showPolicies =
policiesEnabled &&
policiesVisible &&
!allToolsView &&
leftPanelView === "toolPicker";
// When Policies are shown, the search moves OUT of the header to sit between
// the Policies and Tools sections (separating them); otherwise it stays in the
// header. Show the header search when there's a close button, or in the
// default tool-picker view.
const showInlineSearch = showPolicies && !showCloseButton;
const showHeaderSearch =
!showInlineSearch && (showCloseButton || leftPanelView === "toolPicker");
const showHeaderSearch = showCloseButton || leftPanelView === "toolPicker";
const handleHeaderBack = () => {
if (inToolView) {
@@ -165,15 +129,7 @@ export default function RightSidebar() {
? (toolRegistry[selectedToolKey as ToolId] ?? null)
: null;
// The detail takeover replaces the tool list ONLY in the same default view —
// never over an open tool or the all-tools view (which must keep priority).
// A lingering selection is harmless: it stays hidden behind a tool and the
// list/takeover reappears on return to the picker (as in the prototype).
const policyDetailActive = rawPolicyDetailActive && showPolicies;
// The rail widens when a policy detail takes it over — the tool list is fine
// at 18.5rem, but the policy detail/wizard/settings need more breathing room.
const expandedWidth = policyDetailActive ? "25rem" : "18.5rem";
const expandedWidth = "18.5rem";
const computedWidth = () => {
if (isMobile) return "100%";
@@ -236,9 +192,6 @@ export default function RightSidebar() {
</ActionIcon>
</div>
<div className="tool-panel__collapsed-divider" />
{policiesEnabled && (
<PoliciesCollapsedButton onExpand={handleOpenPolicy} />
)}
<div className="tool-panel__collapsed-tools">
{collapsedRailItems.map(({ id, tool }) => (
<AppTooltip
@@ -283,109 +236,74 @@ export default function RightSidebar() {
flexDirection: "column",
}}
>
{policyDetailActive ? (
<div className="pol-takeover">
<PolicyDetailTakeover />
</div>
) : (
<>
{!showPolicies &&
(activeTool ? (
<ToolPanelHeader
icon={
<ToolIcon
icon={activeTool.icon}
marginRight="0"
color="currentColor"
/>
}
title={activeTool.name}
onClose={handleHeaderBack}
closeLabel={
<>
{activeTool ? (
<ToolPanelHeader
icon={
<ToolIcon
icon={activeTool.icon}
marginRight="0"
color="currentColor"
/>
}
title={activeTool.name}
onClose={handleHeaderBack}
closeLabel={
inToolView
? t("toolPanel.backToAllTools", "Back to all tools")
: t("toolPanel.goBack", "Go back")
}
/>
) : (
<div className="tool-panel__compact-header">
{showHeaderSearch ? (
<div className="tool-panel__compact-header-search">
<ToolSearch
value={searchQuery}
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
autoFocus={allToolsView && !inToolView}
/>
</div>
) : null}
{showCloseButton ? (
<ActionIcon
variant="tertiary"
size="md"
shape="circle"
onClick={handleHeaderBack}
aria-label={
inToolView
? t("toolPanel.backToAllTools", "Back to all tools")
: t("toolPanel.goBack", "Go back")
}
/>
className="tool-panel__expand-btn"
>
<CloseIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
) : (
<div className="tool-panel__compact-header">
{showHeaderSearch ? (
<div className="tool-panel__compact-header-search">
<ToolSearch
value={searchQuery}
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
autoFocus={allToolsView && !inToolView}
/>
</div>
) : null}
{showCloseButton ? (
<ActionIcon
variant="tertiary"
size="md"
shape="circle"
onClick={handleHeaderBack}
aria-label={
inToolView
? t("toolPanel.backToAllTools", "Back to all tools")
: t("toolPanel.goBack", "Go back")
}
className="tool-panel__expand-btn"
>
<CloseIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
) : (
<ActionIcon
variant="secondary"
size="md"
shape="circle"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn tool-panel__toggle-vt"
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
)}
</div>
))}
<ActionIcon
variant="secondary"
size="md"
shape="circle"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn tool-panel__toggle-vt"
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
)}
</div>
)}
{showPolicies && (
<PoliciesSection
leadingControl={
<ActionIcon
aria-label={t("toolPanel.collapse", "Collapse panel")}
variant="secondary"
size="md"
shape="circle"
onClick={handleCollapse}
className="tool-panel__expand-btn tool-panel__toggle-vt"
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
}
/>
)}
{showInlineSearch && (
<div className="tool-panel__between-search">
<ToolSearch
value={searchQuery}
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
/>
</div>
)}
<ToolPanel
allToolsView={allToolsView}
onShowAllTools={handleShowAllTools}
onToolSelect={handleToolSelectWithTransition}
compact={false}
/>
</>
)}
<ToolPanel
allToolsView={allToolsView}
onShowAllTools={handleShowAllTools}
onToolSelect={handleToolSelectWithTransition}
compact={false}
/>
</>
</div>
)}
@@ -1,77 +0,0 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
/**
* Policy editing is admin-only (mirrors the backend's enforcement on the
* save/delete endpoints). The frontend gate is `canConfigure = !enableLogin ||
* isAdmin`, surfaced through `usePolicies()`:
* - login enabled + non-admin read-only: the setup wizard shows the
* "Managed by your organization" locked state instead of the steps.
* - login enabled + admin full setup flow ("Step 1 of 2").
* - login disabled (single-user/desktop) open to the local operator.
*
* Backend-free: the app-config (`enableLogin`/`isAdmin`) and the empty policy
* list are stubbed via `mockAppApis`, so this asserts the gating wiring without
* a live Spring Boot server. "Security" is the only non-coming-soon policy, so
* it's the one we open.
*/
const LOCKED_TITLE = "Managed by your organization";
const LOCKED_DESC = "Contact a team leader to change this policy.";
/** Open the Security policy from the right-sidebar Policies list. */
async function openSecurityPolicy(page: import("@playwright/test").Page) {
const row = page.locator("button.pol-row").filter({ hasText: "Security" });
await expect(row).toBeVisible({ timeout: 15_000 });
await row.click();
// The wizard header confirms we opened the right policy in either state.
await expect(page.getByText("Set up Security Policy")).toBeVisible();
}
// Policies are a SaaS-only feature: POLICIES_ENABLED is off in the proprietary
// and core builds this stubbed suite runs against, so the policy UI never
// renders here. Skip unless the app under test is built with policies enabled
// and the runner opts in via POLICIES_E2E=1.
test.beforeEach(() => {
const enabled = ["1", "true"].includes(process.env.POLICIES_E2E ?? "");
test.skip(!enabled, "Policies are SaaS-only; set POLICIES_E2E=1 to run");
});
test.describe("Policy editing gate — non-admin (login on)", () => {
test.use({
stubOptions: { enableLogin: true, isAdmin: false },
seedJwt: true,
});
test("non-admin gets the read-only locked state", async ({ page }) => {
await openSecurityPolicy(page);
await expect(page.getByText(LOCKED_TITLE)).toBeVisible();
await expect(page.getByText(LOCKED_DESC)).toBeVisible();
// The editable flow must NOT be reachable.
await expect(page.getByText(/Step \d+ of \d+/)).toHaveCount(0);
});
});
test.describe("Policy editing gate — admin (login on)", () => {
test.use({
stubOptions: { enableLogin: true, isAdmin: true },
seedJwt: true,
});
test("admin can reach the setup wizard", async ({ page }) => {
await openSecurityPolicy(page);
await expect(page.getByText(/Step \d+ of \d+/)).toBeVisible();
await expect(page.getByText(LOCKED_TITLE)).toHaveCount(0);
});
});
test.describe("Policy editing gate — single-user (login off)", () => {
test.use({ stubOptions: { enableLogin: false, isAdmin: false } });
test("local operator can reach the setup wizard with no admin role", async ({
page,
}) => {
await openSecurityPolicy(page);
await expect(page.getByText(/Step \d+ of \d+/)).toBeVisible();
await expect(page.getByText(LOCKED_TITLE)).toHaveCount(0);
});
});
@@ -1,25 +0,0 @@
/**
* Desktop shadow of the proprietary Policies right-rail.
*
* Re-exports the proprietary module unchanged EXCEPT `usePoliciesEnabled`, which
* additionally requires an active SaaS connection. Policy runs execute + bill via
* the cloud (POST /api/v1/policies/.../run hits the SaaS backend), so on desktop
* the feature must be hidden in local ("disconnected") and self-hosted modes
* otherwise the panel + auto-run controller would fire policy runs against a
* backend that doesn't serve them. On web the build flavor already gates it.
*
* `usePoliciesEnabled` is the single gate RightSidebar uses for BOTH the rail
* section and mounting PolicyAutoRunController, so this one override covers both.
*/
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
import { useConfirmedSaaSMode } from "@app/hooks/useConfirmedSaaSMode";
export * from "@proprietary/components/policies/PoliciesSidebar";
export function usePoliciesEnabled(): boolean {
// Pessimistic SaaS-mode check (starts false): this gate also controls whether
// PolicyAutoRunController mounts, and that fires GET /api/v1/policies on mount.
// useSaaSMode()'s optimistic-true default would leak that request against the
// local/self-hosted backend on cold start before the mode resolves.
return POLICIES_ENABLED && useConfirmedSaaSMode();
}
@@ -0,0 +1,19 @@
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
import { useConfirmedSaaSMode } from "@app/hooks/useConfirmedSaaSMode";
/**
* Desktop shadow: policy runs execute + bill via the cloud (POST
* /api/v1/policies/.../run hits the SaaS backend), so the feature must stay
* off in local ("disconnected") and self-hosted modes otherwise the
* auto-run controller would fire policy runs against a backend that doesn't
* serve them.
*
* Pessimistic SaaS-mode check (starts false): this gate controls whether
* PolicyAutoRunController mounts, and that fires GET /api/v1/policies on
* mount. useSaaSMode()'s optimistic-true default would leak that request
* against the local/self-hosted backend on cold start before the mode
* resolves.
*/
export function usePoliciesEnabled(): boolean {
return POLICIES_ENABLED && useConfirmedSaaSMode();
}
@@ -2,8 +2,8 @@
* Desktop-build feature gates. Shadows `proprietary/constants/featureFlags.ts`
* (the desktop `@app/*` alias has no saas layer). Re-exports the proprietary
* flags and re-enables Policies: the desktop Policies gate additionally requires
* an active SaaS connection (see desktop PoliciesSidebar's `usePoliciesEnabled`),
* so the flag must be on for that runtime check to ever apply.
* an active SaaS connection (see the desktop `usePoliciesEnabled` shadow), so
* the flag must be on for that runtime check to ever apply.
*/
export * from "@proprietary/constants/featureFlags";
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
// Resolves to the SaaS override (src/portal-saas) via the @portal cascade.
import { availableOutputModes } from "@portal/components/pipelines/outputModes";
describe("availableOutputModes (SaaS)", () => {
it("offers only s3: no server filesystem, and inline results would expire unseen", () => {
expect(availableOutputModes()).toEqual(["s3"]);
});
});
@@ -0,0 +1,12 @@
import type { PipelineOutputMode } from "@portal/api/pipelines";
/**
* Hosted deployments never write to the server's filesystem (the backend's
* FolderAccessGuard denies it outright), so folder outputs are not offered in
* the pipeline builder. Inline is not offered either: inline results live in
* transient job storage with no portal download surface, so for an unattended
* pipeline they would simply expire unseen.
*/
export function availableOutputModes(): PipelineOutputMode[] {
return ["s3"];
}
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
// Resolves to the SaaS override (src/portal-saas) via the @portal cascade.
import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes";
describe("creatableSourceTypes (SaaS)", () => {
it("never offers folder sources: hosted deployments do not read the server filesystem", () => {
expect(creatableSourceTypes().map((t) => t.type)).not.toContain("folder");
});
it("still offers the cloud source types", () => {
expect(creatableSourceTypes().map((t) => t.type)).toContain("s3");
});
});
@@ -0,0 +1,13 @@
import {
CREATABLE_SOURCE_TYPES,
type CreatableSourceType,
} from "@portal/components/sources/sourceTypes";
/**
* 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");
}
+22 -5
View File
@@ -29,6 +29,9 @@ export interface OutputSpec {
options: Record<string, unknown>;
}
/** The output destinations the pipeline builder can offer. */
export type PipelineOutputMode = "inline" | "folder" | "s3";
/**
* The stored policy record: the create/update body (`id` blank on create) and what
* the backend returns from GET/POST. Mirrors Policy.java exactly; `owner`/`teamId`
@@ -150,12 +153,26 @@ export async function fetchTriggers(): Promise<TriggerInfo[]> {
}
/**
* POST /api/v1/policies/{id}/trigger: run the pipeline now against its configured
* sources, regardless of the enabled flag. Returns the ids of the runs started
* (empty when the sources yielded no work); poll {@link fetchRun} for each.
* What a manual trigger found and started. Mirrors the backend `SweepOutcome`:
* when `runIds` is empty, the counts say why - no files listed, all already
* processed at their current version, parked by a failed run, or still in
* flight from an earlier sweep.
*/
export async function triggerPipeline(id: string): Promise<string[]> {
return apiClient.local.json<string[]>(
export interface TriggerOutcome {
runIds: string[];
filesListed: number;
alreadyProcessed: number;
parked: number;
inFlight: number;
}
/**
* POST /api/v1/policies/{id}/trigger: run the pipeline now against its configured
* sources, regardless of the enabled flag. Returns the runs started plus what the
* sweep skipped; poll {@link fetchRun} for each run id.
*/
export async function triggerPipeline(id: string): Promise<TriggerOutcome> {
return apiClient.local.json<TriggerOutcome>(
`/api/v1/policies/${encodeURIComponent(id)}/trigger`,
{ method: "POST" },
);
+50 -5
View File
@@ -73,6 +73,16 @@ export const JOURNEY: JourneyStep[] = [
},
];
/**
* The commercial flow's stepper stages. The real backend collapses quote + agreement into one
* accept step (accepting the issued quote is accepting the agreement), so the flow shows one fewer
* step than the mock ledger's {@link JOURNEY} the separate "Agreement" step is dropped. Reuses
* JOURNEY's i18n keys.
*/
export const FLOW_JOURNEY: JourneyStep[] = JOURNEY.filter(
(s) => s.stage !== "security",
);
/* ──────────────────────────────────────────────────────────────────────── */
/* Deal header */
/* ──────────────────────────────────────────────────────────────────────── */
@@ -284,12 +294,18 @@ export interface QuoteResult {
currency: string;
annualNetMinor: number;
tcvMinor: number;
/** First post-term renewal fee after the CPI escalator; the committed term itself is flat. */
renewalAnnualNetMinor: number;
/** The fixed CPI escalator applied per renewal, as a whole percent (e.g. 3). */
cpiRatePct: number;
lineItems: QuoteLineItem[];
validUntil: string | null;
/** The Stripe Quote id once issued; null while still a local draft. */
stripeQuoteId: string | null;
/** Hosted Stripe invoice URL, present once the quote is accepted and the subscription invoice exists. */
invoiceUrl: string | null;
/** Direct PDF link for that invoice; persisted so the download button survives a reload. */
invoicePdf: string | null;
/** The inputs this quote was priced from, so the builder can seed itself on re-edit. */
config: QuoteConfigInput;
}
@@ -306,6 +322,10 @@ export interface AcceptResult {
export interface ProcurementSnapshot {
dealId: number | null;
stage: DealStage | null;
/** cloud | selfhost | airgap — chosen at the trial-setup step; seeds the quote builder. */
deployment: string;
/** Seat count captured at trial setup (0 = unspecified); seeds the builder's volume estimate. */
seats: number;
trialStartedAt: string | null;
trialEndsAt: string | null;
trialExtensionsUsed: number;
@@ -318,17 +338,35 @@ export interface ProcurementSnapshot {
export interface QuoteConfigInput {
volume: number;
users: number;
/** Policy posture (governance) as runs per PDF: Essentials 2, Governed 4, Regulated 7. */
intensity: number;
/** PDF-size tier multiplier on the rate: Compact 1.0, Standard 1.4, Heavy 2.4. */
sizeMult: number;
/** cloud | selfhost | airgap — set at the trial; drives the flat deployment fee + offline .lic. */
deployment: string;
termYears: number;
serviceLevel: string;
indemnification: boolean;
training: boolean;
qbr: boolean;
/** Offline / air-gapped licence file — a paid add-on. */
offlineLicense: boolean;
currency: string;
/** Buyer's company name — shown on the quote/agreement and remembered when re-editing. */
businessName: string;
// Buyer / AP details (all optional). They flow onto the Stripe customer (name + bill-to address)
// and the invoice (PO number + tax id as custom fields). Country + currency are out of scope.
/** Signatory / main contact name. */
contactName?: string;
/** Contact email (billing / signatory). */
contactEmail?: string;
addressLine1?: string;
addressLine2?: string;
city?: string;
/** State / province / region. */
region?: string;
postalCode?: string;
/** Purchase-order number, shown on the invoice for AP matching. */
poNumber?: string;
/** VAT / Tax ID, shown on the invoice. */
taxId?: string;
}
export function fetchSnapshot(): Promise<ProcurementSnapshot> {
@@ -343,10 +381,17 @@ export function fetchLicenseFile(): Promise<string> {
return apiClient.saas.text("/api/v1/procurement/license/file");
}
export function startTrial(): Promise<ProcurementSnapshot> {
/**
* Start the trial with the buyer's chosen deployment target and seat count (captured in the setup
* step). These seed the quote builder; both remain editable when the quote is built.
*/
export function startTrial(
deployment: string,
seats: number,
): Promise<ProcurementSnapshot> {
return apiClient.saas.json<ProcurementSnapshot>(
"/api/v1/procurement/trial/start",
{ method: "POST" },
{ method: "POST", body: { deployment, users: seats } },
);
}
@@ -1,4 +1,5 @@
import type { Tier } from "@portal/contexts/TierContext";
import { useUI } from "@portal/contexts/UIContext";
import { WelcomeBanner } from "@portal/components/WelcomeBanner";
import { EditorStatusCard } from "@portal/components/EditorStatusCard";
import { SetupChecklist } from "@portal/components/SetupChecklist";
@@ -19,14 +20,22 @@ import { useProcurement } from "@portal/components/procurement/useProcurement";
* open them.
*/
export function HomeHero({ tier }: { tier: Tier }) {
const { openLinkModal } = useUI();
const procurement = useProcurement();
const dealActive =
procurement.isLinked && procurement.started && !!procurement.data;
// Start the enterprise flow right here on Home: open the trial-setup modal when the account is
// linked, otherwise prompt to link first — no navigating off to the procurement view.
const onStartEnterprise = () => {
if (procurement.isLinked) procurement.onStartTrial();
else openLinkModal();
};
const footer = dealActive ? (
<ControlledDealStatusHero controller={procurement} />
) : (
<SetupChecklist />
<SetupChecklist onStartEnterprise={onStartEnterprise} />
);
return (
@@ -18,10 +18,17 @@ const EDITOR_DOWNLOAD_URL = "https://stirling.com/download";
/**
* Enterprise on-ramp rung. The CTA differs by tier: free orgs start a guided
* trial, subscribed (paying) orgs jump straight to a quote both land in the
* procurement flow.
* trial, subscribed (paying) orgs jump straight to a quote both open the
* procurement flow. When {@code onStart} is given the CTA opens the flow's setup
* modal over Home; otherwise it falls back to navigating to the procurement view.
*/
function EnterpriseRung({ paying }: { paying: boolean }) {
function EnterpriseRung({
paying,
onStart,
}: {
paying: boolean;
onStart?: () => void;
}) {
const { t } = useTranslation();
const { setActiveView } = useView();
return (
@@ -38,7 +45,7 @@ function EnterpriseRung({ paying }: { paying: boolean }) {
<Button
variant="secondary"
size="sm"
onClick={() => setActiveView("procurement")}
onClick={onStart ?? (() => setActiveView("procurement"))}
rightSection={<span aria-hidden></span>}
>
{t(
@@ -77,7 +84,13 @@ interface Step {
* (the same data the Policies / Sources pages show). The header doubles as a
* dismiss control; the Enterprise rung persists regardless.
*/
export function SetupChecklist() {
export function SetupChecklist({
onStartEnterprise,
}: {
/** Start the enterprise flow in place (opens the setup modal over Home). Falls back to
* navigating to the procurement view when omitted (e.g. in isolated stories). */
onStartEnterprise?: () => void;
} = {}) {
const { t } = useTranslation();
const { tier } = useTier();
const { setActiveView } = useView();
@@ -212,7 +225,7 @@ export function SetupChecklist() {
</>
)}
<EnterpriseRung paying={tier !== "free"} />
<EnterpriseRung paying={tier !== "free"} onStart={onStartEnterprise} />
</div>
);
}
@@ -0,0 +1,11 @@
import type { PipelineOutputMode } from "@portal/api/pipelines";
/**
* The output destinations the pipeline builder offers. An extension point:
* deployments where a destination cannot work shadow this module and filter
* the list (e.g. hosted deployments never write to the server's filesystem,
* so folder outputs are not offered there).
*/
export function availableOutputModes(): PipelineOutputMode[] {
return ["inline", "folder", "s3"];
}
@@ -5,6 +5,8 @@ import type { ProcurementSnapshot } from "@portal/api/procurement";
const base: ProcurementSnapshot = {
dealId: 1,
stage: "trial",
deployment: "cloud",
seats: 250,
trialStartedAt: "2026-06-25T00:00:00Z",
trialEndsAt: "2026-07-09T00:00:00Z",
trialExtensionsUsed: 0,
@@ -21,7 +23,7 @@ const meta: Meta<typeof DealStatusHero> = {
args: {
canSchedule: true,
onExpand: () => {},
onKeyDocs: () => {},
onLicense: () => {},
onInvite: () => {},
onSchedule: () => {},
onManageTrial: () => {},
@@ -1,13 +1,16 @@
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import type { ViewId } from "@portal/contexts/ViewContext";
import { JOURNEY, type ProcurementSnapshot } from "@portal/api/procurement";
import {
FLOW_JOURNEY,
type ProcurementSnapshot,
} from "@portal/api/procurement";
import { StageStepper } from "@portal/components/procurement/StageStepper";
import "@portal/views/Procurement.css";
/**
* The enterprise deal-status hero on Home (procurement lives here, not as a nav tab). Adapts to the
* deal stage: quick-action chips (trial countdown manage, key documents, invite teammates,
* deal stage: quick-action chips (trial countdown manage, licence key, invite teammates,
* schedule a call), a rollout checklist during the trial, and a stage-specific primary CTA that
* expands the flow into the takeover modal. Matches the marketing prototype.
*/
@@ -16,7 +19,7 @@ export function DealStatusHero({
busy = false,
canSchedule,
onExpand,
onKeyDocs,
onLicense,
onInvite,
onSchedule,
onManageTrial,
@@ -28,7 +31,7 @@ export function DealStatusHero({
* "Schedule a call" action only appears when the org has linked its account. */
canSchedule: boolean;
onExpand: () => void;
onKeyDocs: () => void;
onLicense: () => void;
onInvite: () => void;
onSchedule: () => void;
onManageTrial: () => void;
@@ -42,11 +45,9 @@ export function DealStatusHero({
? t("portal.procurement.hero.ctaTrial")
: stage === "quote"
? t("portal.procurement.hero.ctaQuote")
: stage === "security"
? t("portal.procurement.hero.ctaAgreement")
: stage === "procurement"
? t("portal.procurement.hero.ctaPayment")
: t("portal.procurement.hero.ctaLive");
: stage === "procurement"
? t("portal.procurement.hero.ctaPayment")
: t("portal.procurement.hero.ctaLive");
const setupSteps: { title: string; sub: string; view: ViewId }[] = [
{
@@ -89,13 +90,13 @@ export function DealStatusHero({
})}
</button>
)}
{stage !== "active" && (
{snapshot.licenseKey && (
<button
type="button"
className="portal-hero__chip portal-hero__chip--action"
onClick={onKeyDocs}
onClick={onLicense}
>
{t("portal.procurement.hero.keyDocs")}
{t("portal.procurement.hero.licenseKey")}
</button>
)}
{stage !== "active" && (
@@ -120,7 +121,7 @@ export function DealStatusHero({
</div>
<div className="portal-hero__stepper">
<StageStepper journey={JOURNEY} currentStage={stage} />
<StageStepper journey={FLOW_JOURNEY} currentStage={stage} />
</div>
{inTrial && (
@@ -15,16 +15,24 @@ import "@portal/views/Procurement.css";
export function ProcurementAgreement({
quote,
busy,
downloading,
onAgree,
onDownload,
onEdit,
}: {
quote: QuoteResult;
busy: boolean;
downloading: boolean;
/** Accept the quote straight into a committed subscription (this is also the agreement). */
onAgree: () => void;
onDownload: () => void;
onEdit: () => void;
}) {
const { t } = useTranslation();
const [checked, setChecked] = useState(false);
const annual = money(quote.annualNetMinor, quote.currency);
const tcv = money(quote.tcvMinor, quote.currency);
const renewal = money(quote.renewalAnnualNetMinor, quote.currency);
const years = quote.config.termYears;
return (
@@ -73,7 +81,19 @@ export function ProcurementAgreement({
))}
</ul>
<h4>3. End-User License Agreement</h4>
<h4>3. Term, renewal and annual fee adjustment</h4>
<p>
This Agreement runs for the committed {years}-year term set out in the
Order Form. It then renews automatically for successive one-year terms
unless either party gives written notice of non-renewal at least 30
days before the end of the then-current term. On each renewal the
annual fee increases by {quote.cpiRatePct}%, a fixed CPI adjustment.
Based on this quote, the first renewal year would be approximately{" "}
<strong>{renewal}</strong> per year; the committed term above is
billed at the rate in the Order Form and is not affected.
</p>
<h4>4. End-User License Agreement</h4>
<p>
Subject to the terms of this Agreement, Stirling grants Customer a
non-exclusive, non-transferable right to use the Service for its
@@ -82,7 +102,7 @@ export function ProcurementAgreement({
Service, and all intellectual property in it, remains Stirling's.
</p>
<h4>4. Data Processing Agreement</h4>
<h4>5. Data Processing Agreement</h4>
<p>
Where Stirling processes personal data on Customer's behalf, it does
so only on Customer's documented instructions and applies appropriate
@@ -92,7 +112,7 @@ export function ProcurementAgreement({
reference.
</p>
<h4>5. Acceptance</h4>
<h4>6. Acceptance</h4>
<p>
By agreeing below, Customer accepts this Agreement and the Order Form.
On acceptance, Stirling will issue the committed annual subscription
@@ -120,6 +140,12 @@ export function ProcurementAgreement({
>
{t("portal.procurement.agreement.agreeCta")}
</Button>
<Button variant="secondary" loading={downloading} onClick={onDownload}>
{t("portal.procurement.milestone.download")}
</Button>
<Button variant="tertiary" onClick={onEdit}>
{t("portal.procurement.milestone.edit")}
</Button>
</div>
</Card>
);
@@ -22,7 +22,7 @@ export function ControlledDealStatusHero({
busy={controller.busy}
canSchedule={controller.isLinked}
onExpand={() => controller.setOpen(true)}
onKeyDocs={() => controller.setExtra("docs")}
onLicense={() => controller.setExtra("license")}
onInvite={() => setActiveView("users")}
onSchedule={() => controller.setExtra("schedule")}
onManageTrial={() => controller.setExtra("trial")}
@@ -1,17 +1,17 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import type { ProcurementSnapshot } from "@portal/api/procurement";
import { CalendlyInline } from "@portal/components/procurement/CalendlyInline";
import { LicensePanel } from "@portal/components/procurement/ProcurementStages";
import { useFocusTrap } from "@portal/components/procurement/ProcurementModal";
import "@portal/views/Procurement.css";
/**
* Small centred dialogs that hang off the deal-status hero's quick actions Key documents, Schedule
* a call, and trial management. Schedule a call embeds the live Calendly scheduler; Key documents is
* still mocked for the pilot (static demo data). The shells and wiring are real so the hero behaves
* like the marketing prototype.
* Small centred dialogs that hang off the deal-status hero's quick actions the licence key,
* schedule a call, trial management, and trial setup. Schedule a call embeds the live Calendly
* scheduler. The shells and wiring are real so the hero behaves like the marketing prototype.
*/
function SideModal({
@@ -74,124 +74,45 @@ function SideModal({
);
}
// ── Key documents ────────────────────────────────────────────────────────────
type DocStatus = "available" | "action" | "request";
interface DocRow {
nameKey: string;
subKey: string;
status: DocStatus;
fee?: number;
}
// Static demo catalogue for the pilot; the copy lives in the locale under
// portal.procurement.keyDocs and is resolved via t() at render time.
const STAGE_DOCS: { groupKey: string; docs: DocRow[] }[] = [
{
groupKey: "portal.procurement.keyDocs.groups.yourDeal",
docs: [
{
nameKey: "portal.procurement.keyDocs.docs.formalQuote.name",
subKey: "portal.procurement.keyDocs.docs.formalQuote.sub",
status: "available",
},
{
nameKey: "portal.procurement.keyDocs.docs.msa.name",
subKey: "portal.procurement.keyDocs.docs.msa.sub",
status: "action",
},
{
nameKey: "portal.procurement.keyDocs.docs.bankTransfer.name",
subKey: "portal.procurement.keyDocs.docs.bankTransfer.sub",
status: "available",
},
{
nameKey: "portal.procurement.keyDocs.docs.purchaseOrder.name",
subKey: "portal.procurement.keyDocs.docs.purchaseOrder.sub",
status: "request",
},
],
},
{
groupKey: "portal.procurement.keyDocs.groups.evaluation",
docs: [
{
nameKey: "portal.procurement.keyDocs.docs.soc2.name",
subKey: "portal.procurement.keyDocs.docs.soc2.sub",
status: "available",
},
{
nameKey: "portal.procurement.keyDocs.docs.securityReview.name",
subKey: "portal.procurement.keyDocs.docs.securityReview.sub",
status: "request",
fee: 5000,
},
{
nameKey: "portal.procurement.keyDocs.docs.baa.name",
subKey: "portal.procurement.keyDocs.docs.baa.sub",
status: "request",
fee: 2500,
},
{
nameKey: "portal.procurement.keyDocs.docs.w9.name",
subKey: "portal.procurement.keyDocs.docs.w9.sub",
status: "available",
},
{
nameKey: "portal.procurement.keyDocs.docs.coi.name",
subKey: "portal.procurement.keyDocs.docs.coi.sub",
status: "available",
},
],
},
];
const STATUS_LABEL: Record<DocStatus, string> = {
available: "portal.procurement.keyDocs.status.available",
action: "portal.procurement.keyDocs.status.action",
request: "portal.procurement.keyDocs.status.request",
};
export function KeyDocumentsModal({
// ── Licence key ──────────────────────────────────────────────────────────────
export function LicenseModal({
open,
onClose,
licenseKey,
offlineAvailable,
downloadingLicense,
onDownloadOffline,
trial = false,
}: {
open: boolean;
onClose: () => void;
licenseKey: string;
offlineAvailable: boolean;
downloadingLicense: boolean;
onDownloadOffline: () => void;
/** Licence is still the trial one (not yet upgraded on accept) the downloadable .lic is a
* snapshot, so warn that it must be re-downloaded once the agreement is in place. */
trial?: boolean;
}) {
const { t } = useTranslation();
return (
<SideModal
open={open}
onClose={onClose}
title={t("portal.procurement.keyDocs.title")}
subtitle={t("portal.procurement.keyDocs.subtitle")}
title={t("portal.procurement.license.title")}
subtitle={t("portal.procurement.license.subtitle")}
>
{STAGE_DOCS.map((g) => (
<div key={g.groupKey} className="portal-docs__group">
<div className="portal-docs__group-title">{t(g.groupKey)}</div>
<ul className="portal-docs__list">
{g.docs.map((d) => (
<li key={d.nameKey} className="portal-docs__row">
<div className="portal-docs__row-text">
<span className="portal-docs__row-name">{t(d.nameKey)}</span>
<span className="portal-docs__row-sub">
{t(d.subKey)}
{d.fee
? t("portal.procurement.keyDocs.oneTimeFee", {
amount: `$${d.fee.toLocaleString()}`,
})
: ""}
</span>
</div>
<span
className="portal-docs__row-action"
data-status={d.status}
>
{t(STATUS_LABEL[d.status])}
</span>
</li>
))}
</ul>
</div>
))}
<LicensePanel
licenseKey={licenseKey}
offlineAvailable={offlineAvailable}
downloadingLicense={downloadingLicense}
onDownloadOffline={onDownloadOffline}
/>
{offlineAvailable && trial && (
<p className="portal-proc__license-hint">
{t("portal.procurement.license.trialFileHint")}
</p>
)}
</SideModal>
);
}
@@ -221,6 +142,97 @@ export function ScheduleCallModal({
);
}
// ── Trial setup ────────────────────────────────────────────────────────────
const DEPLOYMENTS = ["cloud", "selfhost", "airgap"] as const;
/**
* Captured before the trial starts: where the buyer will run Stirling (which drives the deployment
* fee and, for air-gapped, the offline licence) and their team size. Both seed the quote builder so
* it opens on their real environment; the trial only begins once this is confirmed.
*/
export function TrialSetupModal({
open,
onClose,
busy,
onConfirm,
}: {
open: boolean;
onClose: () => void;
busy: boolean;
onConfirm: (deployment: string, seats: number) => void;
}) {
const { t } = useTranslation();
const [deployment, setDeployment] = useState<string>("cloud");
const [seats, setSeats] = useState("");
// Reset to defaults each time the dialog opens, so a cancelled setup doesn't linger.
useEffect(() => {
if (open) {
setDeployment("cloud");
setSeats("");
}
}, [open]);
return (
<SideModal
open={open}
onClose={onClose}
title={t("portal.procurement.setup.title")}
subtitle={t("portal.procurement.setup.subtitle")}
footer={
<Button
variant="primary"
accent="premium"
loading={busy}
onClick={() => onConfirm(deployment, Math.max(0, Number(seats) || 0))}
>
{t("portal.procurement.setup.start")}
</Button>
}
>
<label className="portal-qb__field">
<span className="portal-qb__field-label">
{t("portal.procurement.setup.deployment")}
</span>
<div className="portal-qb__opts">
{DEPLOYMENTS.map((d) => (
<button
key={d}
type="button"
className="portal-qb__opt"
data-on={deployment === d || undefined}
onClick={() => setDeployment(d)}
>
<span className="portal-qb__opt-title">
{t(`portal.procurement.setup.${d}`)}
</span>
<span className="portal-qb__opt-sub">
{t(`portal.procurement.setup.${d}Sub`)}
</span>
</button>
))}
</div>
</label>
<label className="portal-qb__field">
<span className="portal-qb__field-label">
{t("portal.procurement.setup.seats")}
</span>
<input
type="number"
min={0}
placeholder={t("portal.procurement.setup.seatsPlaceholder")}
value={seats}
onChange={(e) => setSeats(e.target.value)}
/>
</label>
<p className="portal-sidemodal__text">
{t("portal.procurement.setup.seatsHint")}
</p>
</SideModal>
);
}
// ── Trial management ─────────────────────────────────────────────────────────
export function TrialManageModal({
open,
@@ -2,19 +2,18 @@ import { useTranslation } from "react-i18next";
import { Banner, Button, EmptyState, Skeleton } from "@app/ui";
import { useUI } from "@portal/contexts/UIContext";
import { useLinkedAccountEmail } from "@portal/hooks/useLinkedAccountEmail";
import { JOURNEY } from "@portal/api/procurement";
import { FLOW_JOURNEY } from "@portal/api/procurement";
import { ProcurementAgreement } from "@portal/components/procurement/ProcurementAgreement";
import {
KeyDocumentsModal,
LicenseModal,
ScheduleCallModal,
TrialManageModal,
TrialSetupModal,
} from "@portal/components/procurement/ProcurementExtras";
import { ProcurementModal } from "@portal/components/procurement/ProcurementModal";
import {
LicensePanel,
LiveStageCard,
PaymentStageCard,
QuoteMilestoneCard,
} from "@portal/components/procurement/ProcurementStages";
import { QuoteBuilder } from "@portal/components/procurement/QuoteBuilder";
import { StageStepper } from "@portal/components/procurement/StageStepper";
@@ -22,7 +21,7 @@ import type { ProcurementController } from "@portal/components/procurement/usePr
/**
* The procurement takeover flow: the full-screen journey modal (quote builder
* milestone agreement payment live) plus the key-documents, schedule-call,
* quote & agreement payment live) plus the licence-key, schedule-call, trial-setup,
* and trial-management modals. Driven entirely by a shared ProcurementController
* so it can sit next to a deal-status hero rendered elsewhere (e.g. inside the
* tier hero card on Home).
@@ -56,12 +55,11 @@ export function ProcurementFlow({
extra,
setExtra,
invoicePdf,
onConfirmSetup,
onExtendTrial,
onReset,
onGenerate,
onAcceptQuote,
onAgree,
onGoLive,
onDownloadPdf,
onDownloadOfflineLicense,
} = controller;
@@ -106,70 +104,66 @@ export function ProcurementFlow({
{isLinked && started && (
<>
<div className="portal-proc__modal-stepper">
<StageStepper journey={JOURNEY} currentStage={stage!} />
<StageStepper journey={FLOW_JOURNEY} currentStage={stage!} />
</div>
{(editing ||
(isDraft && (stage === "trial" || stage === "quote"))) && (
<QuoteBuilder
deployment="cloud"
deployment={data?.deployment ?? "cloud"}
seats={data?.seats ?? 0}
initial={latest?.config}
onGenerate={onGenerate}
/>
)}
{!editing && isIssued && stage === "quote" && latest && (
<QuoteMilestoneCard
quote={latest}
busy={busy}
downloading={downloading}
onAccept={onAcceptQuote}
onDownload={onDownloadPdf}
onEdit={() => setEditing(true)}
/>
)}
{!editing && stage === "security" && latest && (
<ProcurementAgreement
quote={latest}
busy={busy}
onAgree={onAgree}
/>
)}
{/* Quote + agreement are one step: review the itemised quote and the agreement, then
accept straight into a committed subscription. Once accepted you can't go back.
("security" is the retired agreement stage still handled so an older deal that
stopped there isn't left blank.) */}
{!editing &&
isIssued &&
(stage === "quote" || stage === "security") &&
latest && (
<ProcurementAgreement
quote={latest}
busy={busy}
downloading={downloading}
onAgree={onAgree}
onDownload={onDownloadPdf}
onEdit={() => setEditing(true)}
/>
)}
{!editing && stage === "procurement" && latest && (
<PaymentStageCard
invoiceUrl={latest.invoiceUrl}
invoicePdf={invoicePdf}
busy={busy}
onSimulate={onGoLive}
invoicePdf={latest.invoicePdf ?? invoicePdf}
/>
)}
{!editing && stage === "active" && <LiveStageCard />}
{data?.licenseKey && (
<LicensePanel
licenseKey={data.licenseKey}
offlineAvailable={!!latest?.config.offlineLicense}
downloadingLicense={downloadingLicense}
onDownloadOffline={onDownloadOfflineLicense}
/>
)}
<div className="portal-proc__reset">
<button type="button" onClick={onReset} disabled={busy}>
{t("portal.procurement.reset")}
</button>
</div>
</>
)}
</ProcurementModal>
<KeyDocumentsModal
open={extra === "docs"}
<TrialSetupModal
open={extra === "setup"}
onClose={() => setExtra(null)}
busy={busy}
onConfirm={onConfirmSetup}
/>
{data?.licenseKey && (
<LicenseModal
open={extra === "license"}
onClose={() => setExtra(null)}
licenseKey={data.licenseKey}
offlineAvailable={data.deployment === "airgap"}
downloadingLicense={downloadingLicense}
onDownloadOffline={onDownloadOfflineLicense}
trial={data.stage !== "procurement" && data.stage !== "active"}
/>
)}
<ScheduleCallModal
open={extra === "schedule"}
onClose={() => setExtra(null)}
@@ -1,8 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Card } from "@app/ui";
import type { QuoteResult } from "@portal/api/procurement";
import { money } from "@portal/components/procurement/format";
import "@portal/views/Procurement.css";
/**
@@ -11,97 +9,13 @@ import "@portal/views/Procurement.css";
* a pure presentational view driven by props; ProcurementHome owns the state and the actions.
*/
/** The issued Stripe Quote as a shareable milestone: itemised, with accept / download / edit. */
export function QuoteMilestoneCard({
quote,
busy,
downloading,
onAccept,
onDownload,
onEdit,
}: {
quote: QuoteResult;
busy: boolean;
downloading: boolean;
onAccept: () => void;
onDownload: () => void;
onEdit: () => void;
}) {
const { t } = useTranslation();
return (
<Card padding="loose">
<span className="portal-proc__eyebrow">
{t("portal.procurement.milestone.eyebrow", {
number: quote.quoteNumber,
})}
</span>
<h3 className="portal-proc__builder-title">
{t("portal.procurement.milestone.title")}
</h3>
{quote.config.businessName && (
<p className="portal-proc__milestone-for">
{t("portal.procurement.milestone.preparedFor", {
company: quote.config.businessName,
})}
</p>
)}
<p className="portal-proc__subtitle">
{t("portal.procurement.milestone.description")}
</p>
<ul className="portal-qb__lines portal-proc__milestone-lines">
{quote.lineItems.map((li) => (
<li key={li.key} data-kind={li.kind}>
<span>{li.label}</span>
<span>
{li.kind === "INCLUDED"
? t("portal.procurement.builder.included")
: money(li.amountMinor, quote.currency)}
</span>
</li>
))}
</ul>
<div className="portal-proc__milestone-totals">
<span className="portal-proc__milestone-annual">
{money(quote.annualNetMinor, quote.currency)}
<small>{t("portal.procurement.milestone.perYear")}</small>
</span>
<span className="portal-proc__milestone-tcv">
{t("portal.procurement.milestone.tcv", {
value: money(quote.tcvMinor, quote.currency),
})}
</span>
</div>
<div className="portal-proc__payment-actions">
<Button
variant="primary"
accent="premium"
loading={busy}
onClick={onAccept}
>
{t("portal.procurement.milestone.accept")}
</Button>
<Button variant="secondary" loading={downloading} onClick={onDownload}>
{t("portal.procurement.milestone.download")}
</Button>
<Button variant="tertiary" onClick={onEdit}>
{t("portal.procurement.milestone.edit")}
</Button>
</div>
</Card>
);
}
/** The subscription-created step: pay/download the first invoice, or (demo) simulate payment. */
/** The subscription-created step: pay or download the first invoice. */
export function PaymentStageCard({
invoiceUrl,
invoicePdf,
busy,
onSimulate,
}: {
invoiceUrl?: string | null;
invoicePdf?: string | null;
busy: boolean;
onSimulate: () => void;
}) {
const { t } = useTranslation();
return (
@@ -133,11 +47,6 @@ export function PaymentStageCard({
)}
</div>
)}
<div className="portal-proc__reset">
<button type="button" onClick={onSimulate} disabled={busy}>
{t("portal.procurement.payment.simulate")}
</button>
</div>
</Card>
);
}
@@ -15,12 +15,19 @@ import {
import "@portal/views/Procurement.css";
const STEPS = ["volume", "plan", "details"] as const;
const TERM_DISCOUNT = [0, 0.05, 0.1, 0.12, 0.15]; // 1..5 years
const SLA_UPLIFT: Record<string, number> = {
standard: 0,
priority: 0.15,
dedicated: 0.3,
};
const TERM_DISCOUNT = [0, 0.03, 0.05, 0.06, 0.07]; // 1..5 years — meter-only discount (D71)
// Governance posture: the intensity (runs per PDF) fed to the committed-volume curve.
const POSTURES = [
{ intensity: 2, key: "essentials" },
{ intensity: 4, key: "governed" },
{ intensity: 7, key: "regulated" },
] as const;
// PDF-size tiers (D93): a multiplier on the rate. Default Standard (×1.4). Mirrors the server.
const SIZE_TIERS = [
{ mult: 1.0, key: "compact" },
{ mult: 1.4, key: "standard" },
{ mult: 2.4, key: "heavy" },
] as const;
/**
* The enterprise quote builder volume commitment &amp; service details. A client-side preview
@@ -30,10 +37,13 @@ const SLA_UPLIFT: Record<string, number> = {
*/
export function QuoteBuilder({
deployment,
seats = 0,
initial,
onGenerate,
}: {
deployment: string;
/** Seat count from the trial setup; seeds the users field + volume estimate on a fresh quote. */
seats?: number;
/** Seed the builder from an existing quote's config (re-editing a quote). */
initial?: QuoteConfigInput;
/** Called with the priced DRAFT quote; the parent issues it as a Stripe Quote. */
@@ -43,17 +53,28 @@ export function QuoteBuilder({
const [step, setStep] = useState(0);
const [cfg, setCfg] = useState<QuoteConfigInput>(
initial ?? {
volume: 1_000_000,
users: 0,
// Users-first: with no seats from the trial, leave volume empty so entering the team size
// auto-fills it (rather than pre-seeding a figure that hides the users → volume estimate).
volume: seats > 0 ? estimateVolume(seats) : 0,
users: Math.max(0, seats),
intensity: 4, // Governed — the default governance posture per the pricing alignment
sizeMult: 1.4, // Standard — the default PDF-size tier (D93)
deployment,
termYears: 3,
serviceLevel: "priority",
indemnification: false,
training: false,
qbr: false,
offlineLicense: false,
currency: "USD",
businessName: "",
contactName: "",
contactEmail: "",
addressLine1: "",
addressLine2: "",
city: "",
region: "",
postalCode: "",
poNumber: "",
taxId: "",
},
);
// A seeded quote carries a volume but no user count, so treat it as manually set.
@@ -159,6 +180,36 @@ export function QuoteBuilder({
title={t("portal.procurement.builder.s2Title")}
sub={t("portal.procurement.builder.s2Sub")}
>
<Field label={t("portal.procurement.builder.posture")}>
<div className="portal-qb__opts">
{POSTURES.map((p) => (
<OptCard
key={p.key}
on={cfg.intensity === p.intensity}
title={t(`portal.procurement.builder.posture_${p.key}`)}
sub={`${t("portal.procurement.builder.posture_count", {
count: p.intensity,
})} · ${t(`portal.procurement.builder.posture_${p.key}Sub`)}`}
onClick={() => set("intensity", p.intensity)}
/>
))}
</div>
</Field>
<Field label={t("portal.procurement.builder.pdfSize")}>
<div className="portal-qb__opts">
{SIZE_TIERS.map((s) => (
<OptCard
key={s.key}
on={cfg.sizeMult === s.mult}
title={t(`portal.procurement.builder.size_${s.key}`)}
sub={`×${s.mult} · ${t(`portal.procurement.builder.size_${s.key}Sub`)}`}
onClick={() => set("sizeMult", s.mult)}
/>
))}
</div>
</Field>
<Field label={t("portal.procurement.builder.term")}>
<div className="portal-qb__pills">
{[1, 2, 3, 4, 5].map((y) => (
@@ -224,12 +275,6 @@ export function QuoteBuilder({
sub={t("portal.procurement.builder.qbrSub")}
onClick={() => set("qbr", !cfg.qbr)}
/>
<AddOn
on={cfg.offlineLicense}
title={t("portal.procurement.builder.offlineLicense")}
sub={t("portal.procurement.builder.offlineLicenseSub")}
onClick={() => set("offlineLicense", !cfg.offlineLicense)}
/>
</div>
</Field>
</Step>
@@ -241,31 +286,97 @@ export function QuoteBuilder({
title={t("portal.procurement.builder.s3Title")}
sub={t("portal.procurement.builder.s3Sub")}
>
<Field label={t("portal.procurement.builder.businessName")}>
<div className="portal-qb__row">
<Field label={t("portal.procurement.builder.businessName")}>
<input
placeholder={t(
"portal.procurement.builder.businessNamePlaceholder",
)}
value={cfg.businessName}
onChange={(e) => set("businessName", e.target.value)}
/>
</Field>
<Field label={t("portal.procurement.builder.contactName")}>
<input
placeholder={t(
"portal.procurement.builder.contactNamePlaceholder",
)}
value={cfg.contactName ?? ""}
onChange={(e) => set("contactName", e.target.value)}
/>
</Field>
</div>
<Field label={t("portal.procurement.builder.contactEmail")}>
<input
type="email"
placeholder={t(
"portal.procurement.builder.contactEmailPlaceholder",
)}
value={cfg.contactEmail ?? ""}
onChange={(e) => set("contactEmail", e.target.value)}
/>
</Field>
<Field label={t("portal.procurement.builder.addressLine1")}>
<input
placeholder={t(
"portal.procurement.builder.businessNamePlaceholder",
"portal.procurement.builder.addressLine1Placeholder",
)}
value={cfg.businessName}
onChange={(e) => set("businessName", e.target.value)}
value={cfg.addressLine1 ?? ""}
onChange={(e) => set("addressLine1", e.target.value)}
/>
</Field>
<Field label={t("portal.procurement.builder.addressLine2")}>
<input
placeholder={t(
"portal.procurement.builder.addressLine2Placeholder",
)}
value={cfg.addressLine2 ?? ""}
onChange={(e) => set("addressLine2", e.target.value)}
/>
</Field>
<div className="portal-qb__row">
<Field label={t("portal.procurement.builder.country")}>
<select
value={cfg.currency}
onChange={(e) => set("currency", e.target.value)}
>
<option value="USD">
{t("portal.procurement.builder.countryUS")}
</option>
<option value="GBP">
{t("portal.procurement.builder.countryUK")}
</option>
<option value="EUR">
{t("portal.procurement.builder.countryEuro")}
</option>
</select>
<Field label={t("portal.procurement.builder.city")}>
<input
placeholder={t("portal.procurement.builder.cityPlaceholder")}
value={cfg.city ?? ""}
onChange={(e) => set("city", e.target.value)}
/>
</Field>
<Field label={t("portal.procurement.builder.region")}>
<input
placeholder={t(
"portal.procurement.builder.regionPlaceholder",
)}
value={cfg.region ?? ""}
onChange={(e) => set("region", e.target.value)}
/>
</Field>
<Field label={t("portal.procurement.builder.postalCode")}>
<input
placeholder={t(
"portal.procurement.builder.postalCodePlaceholder",
)}
value={cfg.postalCode ?? ""}
onChange={(e) => set("postalCode", e.target.value)}
/>
</Field>
</div>
<div className="portal-qb__row">
<Field label={t("portal.procurement.builder.poNumber")}>
<input
placeholder={t(
"portal.procurement.builder.poNumberPlaceholder",
)}
value={cfg.poNumber ?? ""}
onChange={(e) => set("poNumber", e.target.value)}
/>
</Field>
<Field label={t("portal.procurement.builder.taxId")}>
<input
placeholder={t("portal.procurement.builder.taxIdPlaceholder")}
value={cfg.taxId ?? ""}
onChange={(e) => set("taxId", e.target.value)}
/>
</Field>
</div>
<label className="portal-qb__eula">
@@ -283,9 +394,9 @@ export function QuoteBuilder({
<div className="portal-qb__foot">
<span className="portal-qb__running">
{t("portal.procurement.builder.running", {
annual: money(preview, cfg.currency),
annual: money(preview),
years: cfg.termYears,
tcv: money(tcvPreview, cfg.currency),
tcv: money(tcvPreview),
})}
</span>
<div className="portal-qb__foot-btns">
@@ -431,20 +542,31 @@ function estimateVolume(users: number): number {
return Math.round(raw / stepSize) * stepSize;
}
function previewAnnualMinor(cfg: QuoteConfigInput): number {
const perPdf = cfg.volume >= 5_000_000 ? 3 : cfg.volume >= 1_000_000 ? 4 : 5;
const usage = Math.round(cfg.volume * perPdf);
const withSla = Math.round(usage * (1 + (SLA_UPLIFT[cfg.serviceLevel] ?? 0)));
const withInd = cfg.indemnification ? Math.round(withSla * 1.05) : withSla;
const disc = Math.round(
withInd * TERM_DISCOUNT[Math.min(Math.max(cfg.termYears, 1), 5) - 1],
);
// Flat annual add-ons (QBR, offline licence) sit outside the multi-year discount, mirroring the
// server (PricingRates: qbr 800_000, offline licence 1_200_000). TCV preview derives from this.
return (
withInd -
disc +
(cfg.qbr ? 800_000 : 0) +
(cfg.offlineLicense ? 1_200_000 : 0)
);
// Client mirror of the server pricing curve (ProcurementPricingService / quotePricing). The server
// is authoritative; this only drives the live footer estimate. Minor units (cents); the meter
// rounds to whole dollars, exactly like the backend, so the preview matches the issued quote.
// Exported for the pricing-parity test, which pins this client estimate to the mock and the
// server's published fixtures so a rate-card change can't silently desync the footer from the
// issued quote. This copy stays non-authoritative — the backend prices the real quote.
export function previewAnnualMinor(cfg: QuoteConfigInput): number {
const LIST = 0.01;
const FLOOR = 0.005;
const runVol = Math.max(0, cfg.volume) * Math.max(1, cfg.intensity);
const volDisc =
runVol > 1_000_000
? Math.min(0.5, 0.06 * Math.log2(runVol / 1_000_000))
: 0;
const rate = Math.max(FLOOR, LIST * (1 - volDisc)) * (cfg.sizeMult || 1);
const termDisc = TERM_DISCOUNT[Math.min(Math.max(cfg.termYears, 1), 5) - 1];
const meterNet = Math.round(runVol * rate * (1 - termDisc)) * 100; // whole $ → minor units
const support = cfg.serviceLevel === "dedicated" ? 3_000_000 : 0; // std + priority included
const deploy =
cfg.deployment === "airgap"
? 3_600_000
: cfg.deployment === "selfhost"
? 1_200_000
: 0;
const indemnity = cfg.indemnification ? Math.round(meterNet * 0.05) : 0;
const qbr = cfg.qbr ? 800_000 : 0;
return meterNet + support + deploy + indemnity + qbr;
}
@@ -9,8 +9,8 @@ export const USD = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 0,
});
/** Format a minor-unit (cents) amount in the given currency, whole units (no decimals). */
export function money(minor: number, currency: string): string {
/** Format a minor-unit (cents) amount as whole USD (no decimals). USD only for now. */
export function money(minor: number, currency: string = "USD"): string {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: currency || "USD",
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import type { QuoteConfigInput } from "@portal/api/procurement";
import { previewAnnualMinor } from "@portal/components/procurement/QuoteBuilder";
import { priceQuote } from "@portal/mocks/handlers/procurementSaas";
/**
* The D71 run-based curve is written three times: the authoritative Java engine
* (ProcurementPricingService), the client footer estimate (QuoteBuilder.previewAnnualMinor), and
* the MSW mock (procurementSaas.priceQuote). The client + mock are deliberately non-authoritative,
* but nothing stopped them silently drifting from the server.
*
* This pins both TypeScript copies to each other AND to the exact fixtures locked in the Java
* ProcurementPricingServiceTest. If the rate card changes on the server, that Java test breaks
* first; updating these fixtures to match is the reminder to keep the client/mock in step. If the
* client and mock diverge from one another, this breaks on its own.
*
* Keep these numbers identical to ProcurementPricingServiceTest.
*/
function cfg(overrides: Partial<QuoteConfigInput>): QuoteConfigInput {
return {
volume: 0,
users: 0,
intensity: 4,
sizeMult: 1.0,
deployment: "cloud",
termYears: 3,
serviceLevel: "standard",
indemnification: false,
training: false,
qbr: false,
businessName: "",
...overrides,
};
}
// annualNetMinor (USD minor units) — each mirrors an assertion in ProcurementPricingServiceTest.
const FIXTURES: {
name: string;
cfg: QuoteConfigInput;
annualNetMinor: number;
}[] = [
{
name: "Northwind — 6M · Governed · cloud · standard · 3yr",
cfg: cfg({ volume: 6_000_000 }),
annualNetMinor: 16_527_800,
},
{
name: "Northwind, Standard PDF size (rate ×1.4)",
cfg: cfg({ volume: 6_000_000, sizeMult: 1.4 }),
annualNetMinor: 23_138_900,
},
{
name: "acme — 90M · Governed · self-hosted · dedicated · 3yr",
cfg: cfg({
volume: 90_000_000,
deployment: "selfhost",
serviceLevel: "dedicated",
}),
annualNetMinor: 175_200_000,
},
{
name: "1-year term — no meter discount",
cfg: cfg({ volume: 6_000_000, termYears: 1 }),
annualNetMinor: 17_397_700,
},
{
name: "2-year term — 3% off the meter",
cfg: cfg({ volume: 6_000_000, termYears: 2 }),
annualNetMinor: 16_875_700,
},
{
name: "rate floors at half a cent — 100M · Regulated · 1yr",
cfg: cfg({ volume: 100_000_000, intensity: 7, termYears: 1 }),
annualNetMinor: 350_000_000,
},
];
describe("procurement pricing parity (client ↔ mock ↔ server fixtures)", () => {
for (const f of FIXTURES) {
it(`agrees on ${f.name}`, () => {
expect(previewAnnualMinor(f.cfg)).toBe(f.annualNetMinor);
expect(priceQuote(f.cfg).annualNetMinor).toBe(f.annualNetMinor);
});
}
});
@@ -8,16 +8,19 @@ import {
fetchLicenseFile,
fetchQuotePdf,
fetchSnapshot,
goLive,
issueQuote,
resetProcurement,
startAgreement,
startTrial,
type ProcurementSnapshot,
type QuoteResult,
} from "@portal/api/procurement";
export type ProcurementExtra = null | "docs" | "schedule" | "trial";
export type ProcurementExtra =
| null
| "license"
| "schedule"
| "trial"
| "setup";
/**
* Owns the procurement deal state and actions shared by the Home hero footer
@@ -46,13 +49,14 @@ export interface ProcurementController {
extra: ProcurementExtra;
setExtra: (e: ProcurementExtra) => void;
invoicePdf: string | null;
/** Open the trial-setup dialog (deployment + seats) — the trial only starts once it's confirmed. */
onStartTrial: () => void;
/** Confirm the setup dialog: start the trial with the chosen deployment/seats, then open the flow. */
onConfirmSetup: (deployment: string, seats: number) => void;
onExtendTrial: () => void;
onReset: () => void;
onGenerate: (draft: QuoteResult) => void;
onAcceptQuote: () => void;
onAgree: () => void;
onGoLive: () => void;
onDownloadPdf: () => Promise<void>;
onDownloadOfflineLicense: () => Promise<void>;
}
@@ -99,7 +103,14 @@ export function useProcurement(autoOpen = false): ProcurementController {
}
}
const onStartTrial = () => run(startTrial);
// The setup dialog collects deployment + seats first; the trial starts on confirm.
const onStartTrial = () => setExtra("setup");
const onConfirmSetup = (deployment: string, seats: number) =>
run(async () => {
await startTrial(deployment, seats);
setExtra(null);
setOpen(true);
});
const onExtendTrial = () => run(extendTrial);
const onReset = () =>
run(async () => {
@@ -112,15 +123,14 @@ export function useProcurement(autoOpen = false): ProcurementController {
await issueQuote(draft.quoteId);
setEditing(false);
});
// Milestone agreement (security) stage; then agreeing accepts into a subscription.
const onAcceptQuote = () => run(startAgreement);
// Quote + agreement are one step now: agreeing accepts the issued quote straight into a
// committed subscription (Stripe), and provisioning upgrades the licence server-side.
const onAgree = () =>
run(async () => {
if (!latest) return;
const res = await acceptQuote(latest.quoteId);
setInvoicePdf(res.invoicePdf);
});
const onGoLive = () => run(goLive);
async function onDownloadPdf() {
if (!latest) return;
@@ -194,12 +204,11 @@ export function useProcurement(autoOpen = false): ProcurementController {
setExtra,
invoicePdf,
onStartTrial,
onConfirmSetup,
onExtendTrial,
onReset,
onGenerate,
onAcceptQuote,
onAgree,
onGoLive,
onDownloadPdf,
onDownloadOfflineLicense,
};
@@ -74,6 +74,53 @@ describe("ConnectWizard", () => {
});
});
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();
@@ -11,15 +11,16 @@ import {
} 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 {
CREATABLE_SOURCE_TYPES,
defaultOptions,
sourceTypeMeta,
type CreatableSourceType,
} from "@portal/components/sources/sourceTypes";
import "@portal/views/Sources.css";
const DEFAULT_TYPE = CREATABLE_SOURCE_TYPES[0];
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";
@@ -35,9 +36,9 @@ interface ConnectWizardProps {
source?: Source;
}
/** The creatable-type metadata for a source's stored type, falling back to folder. */
/** The creatable-type metadata for a source's stored type, falling back to the first offered. */
function typeFor(type: string | undefined): CreatableSourceType {
return CREATABLE_SOURCE_TYPES.find((t) => t.type === type) ?? DEFAULT_TYPE;
return OFFERED_TYPES.find((t) => t.type === type) ?? DEFAULT_TYPE;
}
/** Source options coerced to strings for the form, defaulted from the type's fields. */
@@ -199,7 +200,7 @@ export function ConnectWizard({
{stepId === "type" && (
<div className="portal-sources__type-grid">
{CREATABLE_SOURCE_TYPES.map((ct) => (
{OFFERED_TYPES.map((ct) => (
<Button
key={ct.type}
variant="tertiary"
@@ -251,6 +252,7 @@ export function ConnectWizard({
/>
) : (
<Input
type={field.control === "password" ? "password" : undefined}
value={options[field.key] ?? ""}
placeholder={
field.placeholderKey ? t(field.placeholderKey) : undefined
@@ -280,7 +282,11 @@ export function ConnectWizard({
<StatTile
key={field.key}
label={t(field.labelKey)}
value={options[field.key] || "—"}
value={
field.control === "password" && options[field.key]
? "********"
: options[field.key] || "—"
}
/>
))}
</div>
@@ -0,0 +1,14 @@
import {
CREATABLE_SOURCE_TYPES,
type CreatableSourceType,
} from "@portal/components/sources/sourceTypes";
/**
* The source types the connect wizard offers. An extension point: deployments
* where a type cannot work shadow this module and filter the list (e.g. hosted
* deployments never read the server's filesystem, so folder sources are not
* offered there).
*/
export function creatableSourceTypes(): CreatableSourceType[] {
return CREATABLE_SOURCE_TYPES;
}
@@ -26,6 +26,11 @@ const SOURCE_TYPE_META: Record<string, SourceTypeMeta> = {
icon: "✏",
accent: "success",
},
s3: {
labelKey: "portal.sources.types.s3.label",
icon: "☁",
accent: "brand",
},
// Read-only rows surfaced from the Infrastructure API keys - shown for visibility
// and usage stats, never creatable or usable as a policy input.
apikey: {
@@ -49,7 +54,7 @@ export function sourceTypeMeta(type: string): SourceTypeMeta {
export interface SourceFieldDef {
key: string;
labelKey: string;
control: "text" | "select";
control: "text" | "password" | "select";
required?: boolean;
placeholderKey?: string;
helperTextKey?: string;
@@ -137,6 +142,70 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [
},
],
},
{
type: "s3",
labelKey: "portal.sources.types.s3.label",
descriptionKey: "portal.sources.types.s3.description",
fields: [
{
key: "bucket",
labelKey: "portal.sources.types.s3.fields.bucket.label",
control: "text",
required: true,
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",
labelKey: "portal.sources.types.s3.fields.prefix.label",
control: "text",
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",
control: "select",
defaultValue: "consume",
helperTextKey: "portal.sources.types.s3.fields.mode.helperText",
options: [
{
value: "consume",
labelKey: "portal.sources.types.s3.fields.mode.options.consume",
},
{
value: "snapshot",
labelKey: "portal.sources.types.s3.fields.mode.options.snapshot",
},
],
},
],
},
];
/** Default option values for a type's create form. */
@@ -165,7 +165,13 @@ export const pipelinesHandlers = [
http.post("/api/v1/policies/:id/trigger", async ({ params }) => {
if (!store.some((p) => p.id === params.id)) return undefined;
await delay(120);
return HttpResponse.json([`run_${Date.now().toString(36)}`]);
return HttpResponse.json({
runIds: [`run_${Date.now().toString(36)}`],
filesListed: 1,
alreadyProcessed: 0,
parked: 0,
inFlight: 0,
});
}),
// Raw policy by id. Only our pipeline ids are served here; everything else falls
@@ -10,6 +10,8 @@ const SAAS = "http://saas.mock";
const EMPTY = {
dealId: null,
stage: null,
deployment: "cloud",
seats: 0,
trialStartedAt: null,
trialEndsAt: null,
trialExtensionsUsed: 0,
@@ -20,41 +22,79 @@ const EMPTY = {
interface Cfg {
volume: number;
users?: number;
intensity: number;
sizeMult: number;
deployment: string;
serviceLevel: string;
termYears: number;
indemnification: boolean;
training: boolean;
qbr: boolean;
offlineLicense: boolean;
currency: string;
businessName?: string;
contactName?: string;
contactEmail?: string;
addressLine1?: string;
addressLine2?: string;
city?: string;
region?: string;
postalCode?: string;
poNumber?: string;
taxId?: string;
}
let deal: typeof EMPTY | (Record<string, unknown> & { latestQuote: unknown }) =
EMPTY;
let seq = 0;
const SLA: Record<string, number> = {
standard: 0,
priority: 0.15,
dedicated: 0.3,
};
const TERM = [0, 0.05, 0.1, 0.12, 0.15];
const TERM = [0, 0.03, 0.05, 0.06, 0.07]; // meter-only, 1..5 years
function priceQuote(cfg: Cfg) {
const perPdf = cfg.volume >= 5_000_000 ? 3 : cfg.volume >= 1_000_000 ? 4 : 5;
const usage = Math.round(cfg.volume * perPdf);
const withSla = Math.round(usage * (1 + (SLA[cfg.serviceLevel] ?? 0)));
const withInd = cfg.indemnification ? Math.round(withSla * 1.05) : withSla;
const disc = Math.round(
withInd * TERM[Math.min(Math.max(cfg.termYears, 1), 5) - 1],
);
// Mirror of ProcurementPricingService (D71): run-based curve, flat priced needs, USD.
// Exported for the pricing-parity test (see pricingParity.test.ts).
export function priceQuote(cfg: Cfg) {
const LIST = 0.01;
const FLOOR = 0.005;
const intensity = Math.max(1, cfg.intensity || 4);
const runVol = Math.max(0, cfg.volume) * intensity;
const volDisc =
runVol > 1_000_000
? Math.min(0.5, 0.06 * Math.log2(runVol / 1_000_000))
: 0;
// File-size tier (D93) scales the rate after the floor; snap to a known multiplier.
const sizeMult = [1.0, 1.4, 2.4].includes(cfg.sizeMult) ? cfg.sizeMult : 1.0;
const rate = Math.max(FLOOR, LIST * (1 - volDisc)) * sizeMult;
const termDisc = TERM[Math.min(Math.max(cfg.termYears, 1), 5) - 1];
const annualBase = Math.round(runVol * rate) * 100; // whole $ → minor
const meterNet = Math.round(runVol * rate * (1 - termDisc)) * 100;
const termDiscount = meterNet - annualBase; // <= 0
const support = cfg.serviceLevel === "dedicated" ? 3_000_000 : 0;
const deploy =
cfg.deployment === "airgap"
? 3_600_000
: cfg.deployment === "selfhost"
? 1_200_000
: 0;
const indemnity = cfg.indemnification ? Math.round(meterNet * 0.05) : 0;
const qbr = cfg.qbr ? 800_000 : 0;
const offline = cfg.offlineLicense ? 1_200_000 : 0;
const training = cfg.training ? 750_000 : 0;
const annualNetMinor = withInd - disc + qbr + offline;
const annualNetMinor = meterNet + support + deploy + indemnity + qbr;
const tcvMinor = annualNetMinor * cfg.termYears + training;
const posture =
intensity === 2
? "Essentials"
: intensity === 7
? "Regulated"
: intensity === 4
? "Governed"
: `${intensity}-policy`;
const deployName =
cfg.deployment === "airgap"
? "Air-gapped"
: cfg.deployment === "selfhost"
? "Self-hosted"
: "Stirling Cloud";
type Kind = "RECURRING" | "ONE_TIME" | "DISCOUNT" | "INCLUDED";
const lines: {
key: string;
@@ -64,33 +104,44 @@ function priceQuote(cfg: Cfg) {
}[] = [
{
key: "usage",
label: "PDF processing",
label: `PDF processing${cfg.volume.toLocaleString()} PDFs/yr at $${(rate * intensity).toFixed(4)}/PDF (${posture} posture)`,
kind: "RECURRING",
amountMinor: usage,
amountMinor: annualBase,
},
{
key: "seats",
label: "Unlimited users + SSO / SCIM / RBAC",
label: "Unlimited users + SSO / SCIM / RBAC / audit",
kind: "INCLUDED",
amountMinor: 0,
},
];
if (withSla !== usage)
if (termDiscount < 0)
lines.push({
key: "service-level",
label:
cfg.serviceLevel === "dedicated"
? "Dedicated service level"
: "Priority service level",
kind: "RECURRING",
amountMinor: withSla - usage,
key: "multi-year",
label: `${cfg.termYears}-year commitment`,
kind: "DISCOUNT",
amountMinor: termDiscount,
});
if (withInd !== withSla)
if (support > 0)
lines.push({
key: "support",
label: "Dedicated SE / CSM",
kind: "RECURRING",
amountMinor: support,
});
if (deploy > 0)
lines.push({
key: "deployment",
label: `${deployName} deployment`,
kind: "RECURRING",
amountMinor: deploy,
});
if (indemnity > 0)
lines.push({
key: "indemnification",
label: "IP indemnification",
kind: "RECURRING",
amountMinor: withInd - withSla,
amountMinor: indemnity,
});
if (qbr > 0)
lines.push({
@@ -99,20 +150,6 @@ function priceQuote(cfg: Cfg) {
kind: "RECURRING",
amountMinor: qbr,
});
if (offline > 0)
lines.push({
key: "offline-license",
label: "Offline / air-gapped licence",
kind: "RECURRING",
amountMinor: offline,
});
if (disc > 0)
lines.push({
key: "multi-year",
label: `${cfg.termYears}-year commitment`,
kind: "DISCOUNT",
amountMinor: -disc,
});
if (training > 0)
lines.push({
key: "training",
@@ -126,25 +163,37 @@ function priceQuote(cfg: Cfg) {
quoteId: seq,
quoteNumber: `QT-DEMO-${String(seq).padStart(4, "0")}`,
status: "draft",
currency: cfg.currency || "USD",
currency: "USD",
annualNetMinor,
tcvMinor,
renewalAnnualNetMinor: Math.round(annualNetMinor * 1.03), // +3% CPI on renewal
cpiRatePct: 3,
lineItems: lines,
validUntil: "2026-07-31",
stripeQuoteId: null,
invoiceUrl: null,
invoicePdf: null,
config: {
volume: cfg.volume,
users: 0,
deployment: "cloud",
intensity,
sizeMult,
deployment: cfg.deployment || "cloud",
termYears: cfg.termYears,
serviceLevel: cfg.serviceLevel,
indemnification: cfg.indemnification,
training: cfg.training,
qbr: cfg.qbr,
offlineLicense: cfg.offlineLicense,
currency: cfg.currency || "USD",
businessName: cfg.businessName ?? "",
contactName: cfg.contactName ?? "",
contactEmail: cfg.contactEmail ?? "",
addressLine1: cfg.addressLine1 ?? "",
addressLine2: cfg.addressLine2 ?? "",
city: cfg.city ?? "",
region: cfg.region ?? "",
postalCode: cfg.postalCode ?? "",
poNumber: cfg.poNumber ?? "",
taxId: cfg.taxId ?? "",
},
};
}
@@ -156,11 +205,20 @@ export function resetProcurementSaasStore() {
export const procurementSaasHandlers = [
http.get(`${SAAS}/api/v1/procurement`, () => HttpResponse.json(deal)),
http.post(`${SAAS}/api/v1/procurement/trial/start`, () => {
http.post(`${SAAS}/api/v1/procurement/trial/start`, async ({ request }) => {
const body = (await request.json().catch(() => ({}))) as Partial<{
deployment: string;
users: number;
}>;
const allowed = ["cloud", "selfhost", "airgap"];
const now = Date.now();
deal = {
dealId: 1,
stage: "trial",
deployment: allowed.includes(body.deployment ?? "")
? body.deployment
: "cloud",
seats: Math.max(0, Number(body.users) || 0),
trialStartedAt: new Date(now).toISOString(),
trialEndsAt: new Date(now + 14 * 86_400_000).toISOString(),
trialExtensionsUsed: 0,
@@ -210,7 +268,8 @@ export const procurementSaasHandlers = [
}),
http.get(`${SAAS}/api/v1/procurement/license/file`, () => {
const q = (deal as { latestQuote: { config?: Cfg } | null }).latestQuote;
if (!q?.config?.offlineLicense) {
// Offline .lic is available only for an air-gapped deployment (matches the Java backend).
if (q?.config?.deployment !== "airgap") {
return new HttpResponse(null, { status: 404 });
}
return new HttpResponse(
@@ -237,16 +296,18 @@ export const procurementSaasHandlers = [
const q = (deal as { latestQuote: Record<string, unknown> | null })
.latestQuote;
const invoiceUrl = "https://invoice.stripe.com/i/mock_procurement";
const invoicePdf = "https://invoice.stripe.com/i/mock_procurement/pdf";
if (q) {
q.status = "accepted";
q.invoiceUrl = invoiceUrl;
q.invoicePdf = invoicePdf;
(deal as Record<string, unknown>).stage = "procurement";
}
return HttpResponse.json({
status: "accepted",
subscriptionId: "sub_mock_procurement",
invoiceUrl,
invoicePdf: "https://invoice.stripe.com/i/mock_procurement/pdf",
invoicePdf,
});
}),
http.post(`${SAAS}/functions/v1/get-procurement-quote-pdf`, () => {
@@ -28,6 +28,8 @@ export const SubscribedInProcurement: Story = {
HttpResponse.json({
dealId: 1,
stage: "trial",
deployment: "cloud",
seats: 250,
trialStartedAt: "2026-07-01T00:00:00.000Z",
trialEndsAt: "2026-07-21T00:00:00.000Z",
trialExtensionsUsed: 0,
@@ -392,3 +392,27 @@
min-width: 1.5rem;
min-height: 1.5rem;
}
.portal-builder__s3-output {
display: flex;
align-items: center;
gap: 0.625rem;
}
.portal-builder__s3-summary {
font-size: 0.8125rem;
color: var(--color-text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.portal-builder__s3-summary.is-unset {
color: var(--color-text-4);
}
.portal-builder__s3-fields {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
@@ -7,7 +7,7 @@ import {
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { Policy } from "@portal/api/pipelines";
import type { Policy, TriggerOutcome } from "@portal/api/pipelines";
import type { ToolRegistryCatalog } from "@app/contexts/ToolRegistryContext";
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import { PipelineBuilder } from "@portal/views/PipelineBuilder";
@@ -45,6 +45,11 @@ vi.mock("@portal/api/sources", () => ({
fetchSources: () => fetchSources(),
}));
const clearProcessedHistory = vi.fn();
vi.mock("@portal/api/policies", () => ({
clearProcessedHistory: (id: string) => clearProcessedHistory(id),
}));
// One editable tool, Compress, so the picker and step settings have something to render.
vi.mock("@app/contexts/ToolRegistryContext", () => {
const compress = {
@@ -100,6 +105,17 @@ const POLICY: Policy = {
output: { type: "inline", options: {} },
};
function outcome(overrides: Partial<TriggerOutcome>): TriggerOutcome {
return {
runIds: [],
filesListed: 0,
alreadyProcessed: 0,
parked: 0,
inFlight: 0,
...overrides,
};
}
function renderBuilder(initial: string) {
return render(
<MemoryRouter initialEntries={[initial]}>
@@ -129,8 +145,10 @@ describe("PipelineBuilder", () => {
fetchSources.mockResolvedValue({ kpis: [], sources: [] });
savePipeline.mockResolvedValue({});
deletePipeline.mockResolvedValue(undefined);
triggerPipeline.mockResolvedValue(["run-1"]);
triggerPipeline.mockResolvedValue(outcome({ runIds: ["run-1"] }));
fetchRun.mockResolvedValue({ status: "COMPLETED" });
clearProcessedHistory.mockReset();
clearProcessedHistory.mockResolvedValue(undefined);
});
it("builds a new pipeline: name it, add a tool, and save", async () => {
@@ -159,6 +177,60 @@ describe("PipelineBuilder", () => {
expect(await screen.findByText("pipelines list")).toBeInTheDocument();
});
it("saves an s3 output with its connection options", async () => {
renderBuilder("/processor/pipelines/new");
fireEvent.change(await screen.findByRole("textbox"), {
target: { value: "Bucket to bucket" },
});
fireEvent.click(screen.getByLabelText("portal.pipelines.output.s3"));
// With s3 selected but no bucket, saving is blocked and the summary reads
// unconfigured; the connection fields live behind the Configure modal.
expect(
screen.getByText("portal.pipelines.composer.create").closest("button"),
).toBeDisabled();
expect(
screen.getByText("portal.pipelines.composer.s3NotConfigured"),
).toBeInTheDocument();
fireEvent.click(screen.getByText("portal.pipelines.composer.s3Configure"));
// Textboxes: name, then the modal's bucket, region, prefix, access key id,
// endpoint; the secret renders as a password input outside the textbox role.
const inputs = screen.getAllByRole("textbox") as HTMLInputElement[];
fireEvent.change(inputs[1], { target: { value: "claims-processed" } });
fireEvent.change(inputs[3], { target: { value: "processed/" } });
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.pipelines.composer.s3Done"));
// The summary now shows the configured destination.
expect(
screen.getByText("s3://claims-processed/processed/"),
).toBeInTheDocument();
fireEvent.click(screen.getByText("portal.pipelines.composer.create"));
await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1));
expect(savePipeline).toHaveBeenCalledWith(
expect.objectContaining({
output: {
type: "s3",
options: {
bucket: "claims-processed",
region: "us-east-1",
prefix: "processed/",
endpoint: "",
accessKeyId: "AKIAEXAMPLE",
secretAccessKey: "shh-secret",
},
},
}),
);
});
it("runs an existing pipeline and reports success", async () => {
renderBuilder("/processor/pipelines/plc-1");
@@ -170,6 +242,45 @@ describe("PipelineBuilder", () => {
).toBeInTheDocument();
});
it("explains an empty trigger when files are parked by a failed run", async () => {
triggerPipeline.mockResolvedValue(outcome({ filesListed: 2, parked: 2 }));
renderBuilder("/processor/pipelines/plc-1");
fireEvent.click(await screen.findByText("portal.pipelines.detail.run"));
expect(
await screen.findByText("portal.pipelines.run.parked"),
).toBeInTheDocument();
});
it("explains an empty trigger when everything is already processed", async () => {
triggerPipeline.mockResolvedValue(
outcome({ filesListed: 3, alreadyProcessed: 3 }),
);
renderBuilder("/processor/pipelines/plc-1");
fireEvent.click(await screen.findByText("portal.pipelines.detail.run"));
expect(
await screen.findByText("portal.pipelines.run.allProcessed"),
).toBeInTheDocument();
});
it("clears processed history from the header and confirms", async () => {
renderBuilder("/processor/pipelines/plc-1");
fireEvent.click(
await screen.findByText("portal.pipelines.detail.clearHistory"),
);
await waitFor(() =>
expect(clearProcessedHistory).toHaveBeenCalledWith("plc-1"),
);
expect(
await screen.findByText("portal.pipelines.run.historyCleared"),
).toBeInTheDocument();
});
it("blocks saving a step that needs an uploaded file", async () => {
renderBuilder("/processor/pipelines/new");
@@ -5,6 +5,7 @@ import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import KeyboardArrowUpRoundedIcon from "@mui/icons-material/KeyboardArrowUpRounded";
import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded";
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
import HistoryRoundedIcon from "@mui/icons-material/HistoryRounded";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded";
import {
@@ -42,9 +43,13 @@ import {
type OutputSpec,
type Policy,
type PolicyRunView,
type PipelineOutputMode,
type TriggerConfig,
type TriggerInfo,
type TriggerOutcome,
} from "@portal/api/pipelines";
import { clearProcessedHistory } from "@portal/api/policies";
import { availableOutputModes } from "@portal/components/pipelines/outputModes";
import { fetchSources, type SourceView } from "@portal/api/sources";
import { useAsync } from "@portal/hooks/useAsync";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
@@ -53,7 +58,29 @@ import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepS
import { ToolPicker } from "@portal/components/pipelines/ToolPicker";
import "@portal/views/PipelineBuilder.css";
type OutputMode = "inline" | "folder";
type OutputMode = PipelineOutputMode;
/** New pipelines (and specs of unoffered types) start on the first offered destination. */
const DEFAULT_OUTPUT_MODE = availableOutputModes()[0];
/** The s3 output's connection fields, mirrored from the OutputSpec options. */
interface S3OutputOptions {
bucket: string;
region: string;
prefix: string;
endpoint: string;
accessKeyId: string;
secretAccessKey: string;
}
const EMPTY_S3_OUTPUT: S3OutputOptions = {
bucket: "",
region: "us-east-1",
prefix: "",
endpoint: "",
accessKeyId: "",
secretAccessKey: "",
};
type ScheduleUnit = "MINUTES" | "HOURS" | "DAYS";
const SCHEDULE_UNITS: ScheduleUnit[] = ["MINUTES", "HOURS", "DAYS"];
@@ -95,14 +122,32 @@ function parseTrigger(trigger: TriggerConfig | null): {
function parseOutput(output: OutputSpec | undefined): {
mode: OutputMode;
directory: string;
s3: S3OutputOptions;
} {
if (output?.type === "folder") {
return {
mode: "folder",
directory: String(output.options?.directory ?? ""),
s3: EMPTY_S3_OUTPUT,
};
}
return { mode: "inline", directory: "" };
if (output?.type === "s3") {
const option = (key: keyof S3OutputOptions, fallback = "") =>
String(output.options?.[key] ?? fallback);
return {
mode: "s3",
directory: "",
s3: {
bucket: option("bucket"),
region: option("region", "us-east-1"),
prefix: option("prefix"),
endpoint: option("endpoint"),
accessKeyId: option("accessKeyId"),
secretAccessKey: option("secretAccessKey"),
},
};
}
return { mode: DEFAULT_OUTPUT_MODE, directory: "", s3: EMPTY_S3_OUTPUT };
}
/**
@@ -149,12 +194,15 @@ export function PipelineBuilder() {
const [triggerType, setTriggerType] = useState<string>(MANUAL);
const [scheduleCount, setScheduleCount] = useState("1");
const [scheduleUnit, setScheduleUnit] = useState<ScheduleUnit>("HOURS");
const [outputMode, setOutputMode] = useState<OutputMode>("inline");
const [outputMode, setOutputMode] = useState<OutputMode>(DEFAULT_OUTPUT_MODE);
const [outputDirectory, setOutputDirectory] = useState("");
const [outputS3, setOutputS3] = useState<S3OutputOptions>(EMPTY_S3_OUTPUT);
const [s3ConfigOpen, setS3ConfigOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [seeded, setSeeded] = useState(false);
const [running, setRunning] = useState(false);
const [clearingHistory, setClearingHistory] = useState(false);
const [runResult, setRunResult] = useState<RunResult | null>(null);
const [pendingDelete, setPendingDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
@@ -186,6 +234,7 @@ export function PipelineBuilder() {
setScheduleUnit(trigger.unit);
setOutputMode(output.mode);
setOutputDirectory(output.directory);
setOutputS3(output.s3);
setSeeded(true);
}, [isEdit, policyState.data, allTools, seeded]);
@@ -222,6 +271,10 @@ export function PipelineBuilder() {
if (selected && !triggerAvailable(selected)) setTriggerType(MANUAL);
}, [triggerType, triggers, triggerAvailable]);
function setS3Field(key: keyof S3OutputOptions, value: string) {
setOutputS3((current) => ({ ...current, [key]: value }));
}
function toggleSource(sourceId: string, checked: boolean) {
setSourceIds((ids) =>
checked
@@ -286,6 +339,7 @@ export function PipelineBuilder() {
scheduleUnit,
outputMode,
outputDirectory,
outputS3,
});
const baseline = useRef<string | null>(null);
useEffect(() => {
@@ -295,7 +349,13 @@ export function PipelineBuilder() {
const scheduleCountValid =
triggerType !== "schedule" || Number(scheduleCount) > 0;
const outputValid = outputMode !== "folder" || outputDirectory.trim() !== "";
const s3OutputValid =
outputMode !== "s3" ||
(outputS3.bucket.trim() !== "" &&
outputS3.accessKeyId.trim() !== "" &&
outputS3.secretAccessKey.trim() !== "");
const outputValid =
(outputMode !== "folder" || outputDirectory.trim() !== "") && s3OutputValid;
const canSave =
name.trim() !== "" &&
scheduleCountValid &&
@@ -357,7 +417,9 @@ export function PipelineBuilder() {
const output: OutputSpec =
outputMode === "folder"
? { type: "folder", options: { directory: outputDirectory.trim() } }
: { type: "inline", options: {} };
: outputMode === "s3"
? { type: "s3", options: { ...outputS3 } }
: { type: "inline", options: {} };
const policy: Policy = {
id: policyState.data?.id ?? undefined,
name: name.trim(),
@@ -387,15 +449,37 @@ export function PipelineBuilder() {
return null;
}
/** Explain an empty trigger: parked files outrank blander reasons. */
function emptySweepResult(outcome: TriggerOutcome): RunResult {
if (outcome.parked > 0) {
return {
tone: "warning",
text: t("portal.pipelines.run.parked", { count: outcome.parked }),
};
}
if (outcome.inFlight > 0) {
return { tone: "info", text: t("portal.pipelines.run.inFlight") };
}
if (outcome.alreadyProcessed > 0) {
return {
tone: "info",
text: t("portal.pipelines.run.allProcessed", {
count: outcome.alreadyProcessed,
}),
};
}
return { tone: "info", text: t("portal.pipelines.run.empty") };
}
async function handleRun() {
if (running || !id) return;
setRunning(true);
setRunResult(null);
try {
const runIds = await triggerPipeline(id);
const outcome = await triggerPipeline(id);
const runIds = outcome.runIds;
if (runIds.length === 0) {
if (mounted.current)
setRunResult({ tone: "info", text: t("portal.pipelines.run.empty") });
if (mounted.current) setRunResult(emptySweepResult(outcome));
return;
}
const finals = await Promise.all(runIds.map((runId) => awaitRun(runId)));
@@ -428,6 +512,30 @@ export function PipelineBuilder() {
}
}
/**
* Forget which source files this pipeline has processed, so the next sweep
* reprocesses everything currently in its sources (the standard retry for a
* parked-by-failure file). Does not touch the files themselves.
*/
async function handleClearHistory() {
if (clearingHistory || !id) return;
setClearingHistory(true);
setRunResult(null);
try {
await clearProcessedHistory(id);
if (mounted.current)
setRunResult({
tone: "success",
text: t("portal.pipelines.run.historyCleared"),
});
} catch (e) {
if (mounted.current)
setRunResult({ tone: "danger", text: errorMessage(e) });
} finally {
if (mounted.current) setClearingHistory(false);
}
}
async function confirmDelete() {
if (!id || deleting) return;
setDeleting(true);
@@ -494,6 +602,17 @@ export function PipelineBuilder() {
>
{t("portal.pipelines.detail.run")}
</Button>
<Button
variant="secondary"
size="sm"
loading={clearingHistory}
onClick={handleClearHistory}
leftSection={
<HistoryRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.pipelines.detail.clearHistory")}
</Button>
<Button
variant="secondary"
size="sm"
@@ -630,10 +749,10 @@ export function PipelineBuilder() {
name="pipeline-output"
value={outputMode}
onChange={setOutputMode}
options={[
{ value: "inline", label: t("portal.pipelines.output.inline") },
{ value: "folder", label: t("portal.pipelines.output.folder") },
]}
options={availableOutputModes().map((mode) => ({
value: mode,
label: t(`portal.pipelines.output.${mode}`),
}))}
/>
{outputMode === "folder" && (
<FormField
@@ -648,6 +767,27 @@ export function PipelineBuilder() {
/>
</FormField>
)}
{outputMode === "s3" && (
<div className="portal-builder__s3-output">
<span
className={
"portal-builder__s3-summary" +
(outputS3.bucket ? "" : " is-unset")
}
>
{outputS3.bucket
? `s3://${outputS3.bucket}/${outputS3.prefix}`
: t("portal.pipelines.composer.s3NotConfigured")}
</span>
<Button
variant="secondary"
size="sm"
onClick={() => setS3ConfigOpen(true)}
>
{t("portal.pipelines.composer.s3Configure")}
</Button>
</div>
)}
</div>
</div>
</section>
@@ -860,6 +1000,78 @@ export function PipelineBuilder() {
>
<p>{t("portal.pipelines.builder.unsavedBody")}</p>
</Modal>
<Modal
open={s3ConfigOpen}
onClose={() => setS3ConfigOpen(false)}
title={t("portal.pipelines.composer.s3ModalTitle")}
footer={
<div className="portal-pipelines__composer-footer">
<Button size="sm" onClick={() => setS3ConfigOpen(false)}>
{t("portal.pipelines.composer.s3Done")}
</Button>
</div>
}
>
<div className="portal-builder__s3-fields">
<FormField
label={t("portal.sources.types.s3.fields.bucket.label")}
required
>
<Input
value={outputS3.bucket}
placeholder="my-company-inbox"
onChange={(e) => setS3Field("bucket", e.target.value)}
/>
</FormField>
<FormField label={t("portal.sources.types.s3.fields.region.label")}>
<Input
value={outputS3.region}
placeholder="us-east-1"
onChange={(e) => setS3Field("region", e.target.value)}
/>
</FormField>
<FormField
label={t("portal.sources.types.s3.fields.prefix.label")}
helperText={t("portal.pipelines.composer.s3PrefixHelp")}
>
<Input
value={outputS3.prefix}
placeholder="processed/"
onChange={(e) => setS3Field("prefix", e.target.value)}
/>
</FormField>
<FormField
label={t("portal.sources.types.s3.fields.accessKeyId.label")}
required
>
<Input
value={outputS3.accessKeyId}
onChange={(e) => setS3Field("accessKeyId", e.target.value)}
/>
</FormField>
<FormField
label={t("portal.sources.types.s3.fields.secretAccessKey.label")}
required
>
<Input
type="password"
value={outputS3.secretAccessKey}
onChange={(e) => setS3Field("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={outputS3.endpoint}
placeholder="https://s3.example.com"
onChange={(e) => setS3Field("endpoint", e.target.value)}
/>
</FormField>
</div>
</Modal>
</div>
);
}
@@ -1,46 +0,0 @@
/**
* Compact status row for the {@link enforcementQueue}, shown in the Policies
* panel whenever enforcement jobs are pending or running. The queue is serial,
* so a slow policy run would otherwise be invisible this surfaces what's being
* enforced (before export, print, convert, ) and how many jobs are waiting.
*/
import { useTranslation } from "react-i18next";
import { Group, Text, Loader } from "@mantine/core";
import { useEnforcementQueue } from "@app/components/policies/enforcementQueue";
export function EnforcementQueueStatus() {
const { t } = useTranslation();
const jobs = useEnforcementQueue();
const active = jobs.filter(
(j) => j.status === "pending" || j.status === "running",
);
if (active.length === 0) return null;
// The running job leads the row; everything else is still queued behind it.
const lead = active.find((j) => j.status === "running") ?? active[0];
const queued = active.length - 1;
return (
<Group
gap="xs"
wrap="nowrap"
px="sm"
py={6}
role="status"
aria-live="polite"
>
<Loader size="xs" />
<Text size="xs" c="dimmed" truncate>
{t(`policies.enforcement.triggerVerb.${lead.trigger}`, {
defaultValue: t("policies.enforcement.triggerVerb.default"),
})}
: {lead.label}
{queued > 0
? ` · ${t("policies.enforcement.queued", { count: queued })}`
: "…"}
</Text>
</Group>
);
}
export default EnforcementQueueStatus;
@@ -1,709 +0,0 @@
/* ============================ Policies ============================ */
/* The Policies surface is docked in the right tool sidebar: a list section */
/* above Tools, a detail takeover that replaces Tools when a policy is open, */
/* and a collapsed-rail of policy icons. */
/* */
/* Chrome (headers, cards, buttons, badges, chips, lists, steps…) uses SUI */
/* (@app/ui). The bespoke .pol-* bits below are thin layout */
/* scaffolding + the collapsed rail; spacing snaps to the SUI --space-* */
/* scale and colour to the SUI token set so it reads as one product. */
/* Dark mode only: remap SUI surface/border tokens to the app's neutral-grey values so policy cards read as one product with the rail; accent tokens are left alone. */
[data-theme="dark"] .pol-list,
[data-theme="dark"] .pol-takeover,
[data-theme="dark"] .pol-detail,
[data-theme="dark"] .pol-crail {
--color-bg: var(--bg-toolbar);
--color-bg-alt: var(--bg-toolbar);
--color-bg-subtle: var(--bg-toolbar);
--color-surface: var(--bg-surface);
--color-surface-alt: #323942;
--color-bg-hover: #323942;
--color-bg-muted: var(--bg-surface);
--color-border: var(--border-default);
--color-border-light: var(--border-subtle);
--color-border-input: var(--border-strong);
--color-border-hover: var(--border-strong);
--color-divider: var(--border-subtle);
--color-dropdown-bg: var(--bg-surface);
--color-dropdown-border: var(--border-default);
}
/* ---- List ---- */
.pol-list {
width: 100%;
display: flex;
flex-direction: column;
}
/* Header row: optional leading control (sidebar collapse) + the SectionHeader,
mirroring the back-button + title layout inside an open policy. */
.pol-list-head {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
}
.pol-list-head .sui-sectionhdr {
flex: 1;
min-width: 0;
}
/* Small "what is a policy?" info button on the header. */
/* Bare info icon matching the tool-step affordance (LocalIcon, no chrome). */
.pol-info-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
border: none;
background: none;
cursor: pointer;
transition: opacity var(--motion-fast);
}
.pol-info-btn:hover {
opacity: 0.7;
}
.pol-list-rows {
display: flex;
flex-direction: column;
padding: var(--space-1) var(--space-1_5);
gap: 0.0625rem;
}
/* A policy row: tinted icon tile + label + trailing status/CTA. */
.pol-row {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-1_5) var(--space-2);
border: none;
border-radius: var(--radius-lg);
background: transparent;
cursor: pointer;
text-align: left;
transition: background var(--motion-fast);
}
.pol-row:hover {
background: var(--color-bg-hover);
}
.pol-row:focus-visible {
outline: 2px solid var(--color-blue);
outline-offset: -2px;
}
/* Processing indicator: a spinning ring around the category icon while the
policy has runs in flight. */
.pol-row-icon {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
--pol-ring: var(--color-blue);
}
.pol-row-icon[data-accent="purple"] {
--pol-ring: var(--color-purple);
}
.pol-row-icon[data-accent="green"] {
--pol-ring: var(--color-green);
}
.pol-row-icon[data-accent="amber"] {
--pol-ring: var(--color-amber);
}
.pol-row-icon[data-accent="red"] {
--pol-ring: var(--color-red);
}
.pol-row-icon[data-accent="orange"] {
--pol-ring: var(--color-orange);
}
.pol-row-icon.is-processing .sui-iconbadge {
--ib-accent: var(--ib-base);
}
.pol-row-ring {
position: absolute;
inset: -3px;
border-radius: 999px;
border: 2px solid color-mix(in srgb, var(--pol-ring) 20%, transparent);
border-top-color: var(--pol-ring);
animation: pol-ring-spin 0.7s linear infinite;
pointer-events: none;
}
/* Global "Retry failed policies (N)" action under the panel header one place
to resume every file the chain stranded, whichever step failed. Amber, so it
reads as "needs attention" without screaming error. */
.pol-retry-failed {
display: flex;
align-items: center;
gap: var(--space-1_5);
width: calc(100% - 2 * var(--space-1_5));
margin: 0 var(--space-1_5) var(--space-1);
padding: var(--space-1) var(--space-2);
border: none;
border-radius: var(--radius-lg);
font-family: inherit;
font-size: 0.75rem;
font-weight: 500;
text-align: left;
cursor: pointer;
color: var(--color-amber, #d97706);
background: color-mix(in srgb, var(--color-amber, #d97706) 12%, transparent);
transition: background var(--motion-fast);
}
.pol-retry-failed:hover {
background: color-mix(in srgb, var(--color-amber, #d97706) 20%, transparent);
}
.pol-retry-failed:focus-visible {
outline: 2px solid var(--color-amber, #d97706);
outline-offset: -2px;
}
@keyframes pol-ring-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.pol-row-ring {
animation-duration: 2s;
}
}
.pol-row-label {
flex: 1;
min-width: 0;
/* Match a normal `sm` button label so icon-bearing rows aren't smaller. */
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-1);
}
.pol-row > .mantine-Button-inner {
width: 100%;
}
.pol-row > .mantine-Button-inner > .mantine-Button-label {
flex: 1;
min-width: 0;
justify-content: flex-start;
overflow: visible;
}
.pol-row-trail {
display: inline-flex;
align-items: center;
gap: var(--space-1);
margin-left: auto;
}
/* Unconfigured rows: a quiet blue "Set up" call-to-action instead of a status pill. */
.pol-row-setup {
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-blue);
}
/* Trailing drill-in chevron on each policy row (after the status / CTA). */
.pol-row-chevron {
color: var(--color-text-4);
flex-shrink: 0;
}
/* ── Policy settings: per-trigger run-order lists ── */
.pol-reorder-section {
margin-top: var(--space-3);
}
.pol-reorder-list {
display: flex;
flex-direction: column;
margin-top: var(--space-1);
}
/* A reorder row: leading grip + tinted icon + label. Square (no radius) so the
drop line reads as one straight rule across the list. */
.pol-reorder-row {
position: relative;
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1_5) var(--space-2);
}
.pol-reorder-row[data-dragging] {
opacity: 0.4;
}
/* Straight, full-width blue insertion line at the drop position (no curves). */
.pol-reorder-row[data-drop="above"]::before,
.pol-reorder-row[data-drop="below"]::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 2px;
background: var(--color-blue);
pointer-events: none;
}
.pol-reorder-row[data-drop="above"]::before {
top: -1px;
}
.pol-reorder-row[data-drop="below"]::after {
bottom: -1px;
}
.pol-reorder-grip {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.25rem;
color: var(--color-text-4);
cursor: grab;
}
.pol-reorder-grip:active {
cursor: grabbing;
}
.pol-reorder-label {
flex: 1;
min-width: 0;
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-1);
}
/* The drag ghost (a cloned row): the whole row with a full blue outline, kept
translucent so the list shows through as it moves. */
.pol-reorder-row--ghost {
border-radius: var(--radius-lg);
outline: 2px solid var(--color-blue);
outline-offset: -2px;
background: var(--color-surface);
box-shadow: var(--shadow-md);
opacity: 0.55;
}
/* Empty-state line for a trigger with no policies. */
.pol-reorder-empty {
margin: var(--space-1) 0 0;
padding: var(--space-1_5) var(--space-2);
font-size: 0.8125rem;
color: var(--color-text-3);
}
/* Retry button on a failed activity row. */
/* Expandable error text in the activity feed long backend errors are clamped
and collapsed by default so they don't blow up the row. */
.pol-activity-error {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
}
.pol-activity-error__text {
white-space: pre-wrap;
word-break: break-word;
}
.pol-activity-error__text--clamped {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.pol-activity-error__toggle {
border: none;
background: none;
padding: 0;
font-size: inherit;
font-weight: 600;
color: var(--color-blue);
cursor: pointer;
}
.pol-activity-error__toggle:hover {
text-decoration: underline;
}
/* Enterprise-only ("coming soon") row shown to admins / team leaders but not
available on the current plan, so the whole box is dimmed to read as disabled.
The row itself isn't a button; its trailing "Upgrade to enterprise" link is. */
.pol-row--soon {
cursor: default;
opacity: 0.55;
}
.pol-row--soon:hover {
background: transparent;
}
/* Trailing "Upgrade to enterprise" link contact us. Greyed to match the
disabled row; still clickable for admins who want to enquire. */
.pol-row-upgrade {
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-text-4);
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: color var(--motion-fast);
}
.pol-row-upgrade:hover {
color: var(--color-text-1);
}
.pol-row-upgrade:hover {
text-decoration: underline;
}
/* In-progress activity icon spins gently. */
.pol-spin {
animation: pol-spin 1.2s linear infinite;
}
@keyframes pol-spin {
to {
transform: rotate(360deg);
}
}
/* ---- Locked, per-tool config (PolicyToolConfig): one section per tool ---- */
.pol-tool-config {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.pol-tool-head {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3);
}
.pol-tool-icon {
display: inline-flex;
align-items: center;
color: var(--color-text-3);
}
.pol-tool-name {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
margin-right: auto;
}
.pol-tool-body {
padding: 0 var(--space-3) var(--space-3);
border-top: 1px solid var(--color-border);
padding-top: var(--space-3);
}
/* ---- Detail container (wizard / narrative / settings share this) ---- */
.pol-detail {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
max-width: 32rem;
width: 100%;
}
/* ---- Step indicator (wraps a SUI StepIndicator) ---- */
.pol-steps {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0 var(--space-5) var(--space-3);
border-bottom: 1px solid var(--color-border);
}
.pol-step-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-4);
}
/* ---- Scroll body ---- */
.pol-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--space-3) var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.pol-desc {
font-size: 0.8125rem;
line-height: 1.5;
color: var(--color-text-4);
margin: 0;
margin-bottom: var(--space-3);
}
.pol-section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-4);
/* Bottom gap so the label doesn't hug its card/chips. */
margin: 0 0 var(--space-2);
}
/* A section label rendered as a collapse toggle (Recent Activity): strip the
button chrome but keep the .pol-section-label typography, chevron pushed right. */
.pol-section-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
font-family: inherit;
}
.pol-section-chevron {
margin-left: auto;
color: var(--color-text-4);
transition: transform 0.15s ease;
}
.pol-section-chevron.is-open {
transform: rotate(180deg);
}
/* Recent-activity feed: cap to ~4.5 rows (and never more than ~45% of the
viewport) then scroll, so a long history doesn't push the stats footer away. */
.pol-activity-list {
max-height: min(22rem, 45vh);
overflow-y: auto;
}
/* Sub-section header inside a settings card (e.g. "Output filename"). The field
directly below it carries data-first so the borders don't double up. */
.pol-subhead {
padding: 0.55rem 0.875rem 0.4rem;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-4);
background: var(--color-surface);
border-top: 1px solid var(--color-border);
}
/* Position dropdown + custom-text input sitting on one row under the
"Output filename" subhead the dropdown sizes to content, text fills. */
.pol-name-row {
display: flex;
gap: var(--space-2);
align-items: center;
}
.pol-name-row > :first-child {
flex: 0 0 auto;
}
.pol-name-row > :last-child {
flex: 1 1 auto;
min-width: 0;
}
/* ---- Fields (PolicyFieldRow) — row inset matches SUI ListRow ---- */
.pol-field {
padding: 0.7rem 0.875rem;
background: var(--color-surface);
}
.pol-field:not([data-first]) {
border-top: 1px solid var(--color-border);
}
.pol-field-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-1);
}
.pol-field-count {
font-size: 0.6875rem;
color: var(--color-text-4);
}
.pol-field-chips-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-2);
}
.pol-field-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-1_5);
}
/* ---- Sources ---- */
.pol-source {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0.7rem 0.875rem;
cursor: pointer;
background: var(--color-surface);
}
.pol-source:not([data-first]) {
border-top: 1px solid var(--color-border);
}
/* ---- Doc types ---- */
.pol-link {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-blue);
background: none;
border: none;
padding: 0;
cursor: pointer;
}
.pol-doctypes-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.7rem 0.875rem;
}
.pol-doctypes {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-2) 0.875rem var(--space-3);
border-top: 1px solid var(--color-border);
}
/* ---- Summary ---- */
.pol-summary-head {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.pol-summary-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
}
.pol-summary-rows {
display: flex;
flex-direction: column;
gap: var(--space-1_5);
}
/* Muted placeholder for an unset summary value (e.g. no reviewer chosen). */
.pol-muted {
color: var(--color-text-4);
}
/* ---- Enforces rule flow (wraps a SUI ChipFlow) ---- */
.pol-rule-flow {
margin-bottom: var(--space-2);
}
/* ---- Meta / note ---- */
.pol-meta-row {
display: flex;
gap: var(--space-3);
padding-top: var(--space-2);
border-top: 1px solid var(--color-border);
}
.pol-meta-item {
display: inline-flex;
align-items: center;
gap: var(--space-1_5);
font-size: 0.75rem;
color: var(--color-text-4);
}
.pol-note {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-lg);
background: var(--color-bg-muted);
font-size: 0.75rem;
color: var(--color-text-4);
}
/* ---- Stats: one grouped card with three divided columns. ---- */
.pol-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.pol-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-0_5);
padding: var(--space-3) var(--space-2);
text-align: center;
}
.pol-stat:not(:first-child) {
border-left: 1px solid var(--color-border);
}
.pol-stat-value {
font-size: 1rem;
font-weight: 600;
line-height: 1.1;
color: var(--color-text-1);
}
.pol-stat-label {
font-size: 0.6875rem;
color: var(--color-text-4);
}
/* ---- Footer (hosts SUI Buttons) ---- */
.pol-footer {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.pol-footer-end {
justify-content: flex-end;
}
/* ---- Detail takeover (fills the rail when a policy is open) ---- */
.pol-takeover {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* ---- Collapsed rail policy icons ---- */
.pol-crail {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
flex-shrink: 0;
/* The collapsed-rail dividers carry 8px of space below them only, so the
divider above gives the row 8px of headroom while the one below (rendered
straight after) hugs it. Match that 8px underneath so the row sits centred
between the two dividers. */
margin-bottom: 8px;
}
.pol-crail-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-3);
cursor: pointer;
transition: background var(--motion-fast);
}
.pol-crail-btn:hover {
background: var(--color-blue-light);
}
.pol-crail-btn[data-status="active"] {
color: var(--color-blue);
}
.pol-crail-btn[data-status="paused"] {
color: var(--color-amber);
}
.pol-crail-dot {
position: absolute;
top: 0.1rem;
right: 0.1rem;
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
border: 2px solid var(--bg-toolbar, var(--color-surface));
}
.pol-crail-dot[data-status="active"] {
background: var(--color-green);
}
.pol-crail-dot[data-status="paused"] {
background: var(--color-amber);
}
@@ -1,163 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PolicyDetailPanel } from "@app/components/policies/PolicyDetailPanel";
import { PoliciesSection } from "@app/components/policies/PoliciesSidebar";
import { POLICY_CATEGORIES, POLICY_CONFIG } from "@app/data/policyDefinitions";
import type { PolicyActivityItem, PolicyStats } from "@app/types/policies";
import "@app/components/policies/Policies.css";
/**
* The Policies surface lives in the editor's right tool sidebar. These stories
* render the three rich detail surfaces (narrative / setup wizard / settings)
* inside a frame the width of the rail when a policy is open (25rem), so the
* SUI composition can be reviewed in isolation no app shell, login, or
* backend required. Toggle the Storybook theme switcher to check dark mode.
*/
const RAIL_WIDTH = "25rem";
/** Frame that mimics the right rail's open width + surface so the panel reads true. */
function RailFrame({ children }: { children: React.ReactNode }) {
return (
<div
style={{
width: RAIL_WIDTH,
height: "780px",
display: "flex",
flexDirection: "column",
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-lg)",
overflow: "hidden",
}}
>
{children}
</div>
);
}
const ingestion = POLICY_CATEGORIES.find((c) => c.id === "ingestion")!;
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
// Illustrative activity/stats for the static stories. In the app these are
// derived live from the user's real uploaded files (see policyLiveData).
const sampleActivity: PolicyActivityItem[] = [
{
doc: "MSA_Acme_2026.pdf",
action: "1.2 MB • enforced on upload",
time: "2h ago",
status: "enforced",
},
{
doc: "scan_002.pdf",
action: "Low confidence • flagged for review",
time: "Yesterday",
status: "flagged",
},
];
const sampleStats: PolicyStats = {
enforced: 1284,
dataProcessed: "3.2 GB",
activeFor: "18d",
};
const noop = () => {};
const meta: Meta = {
title: "Editor/Policies",
parameters: { layout: "centered" },
};
export default meta;
type Story = StoryObj;
/** Configured policy, running — Enforces / Activity / Stats narrative. */
export const DetailActive: Story = {
render: () => (
<RailFrame>
<PolicyDetailPanel
category={ingestion}
config={POLICY_CONFIG.ingestion}
status="active"
steps={POLICY_CONFIG.ingestion.defaultOperations}
activity={[
{
doc: "Q4_Report.pdf",
action: "Enforcing…",
time: "Just now",
status: "processing",
},
...sampleActivity,
]}
stats={sampleStats}
canConfigure
canDelete
onBack={noop}
onEditSettings={noop}
onTogglePause={noop}
onDelete={noop}
/>
</RailFrame>
),
};
/** Configured policy, paused — amber accent + warning badge. */
export const DetailPaused: Story = {
render: () => (
<RailFrame>
<PolicyDetailPanel
category={security}
config={POLICY_CONFIG.security}
status="paused"
activity={sampleActivity}
stats={sampleStats}
canConfigure
canDelete
onBack={noop}
onEditSettings={noop}
onTogglePause={noop}
onDelete={noop}
/>
</RailFrame>
),
};
/** Read-only view for a member without configure permission. */
export const DetailManaged: Story = {
render: () => (
<RailFrame>
<PolicyDetailPanel
category={ingestion}
config={POLICY_CONFIG.ingestion}
status="active"
activity={sampleActivity}
stats={sampleStats}
canConfigure={false}
canDelete={false}
onBack={noop}
onEditSettings={noop}
onTogglePause={noop}
onDelete={noop}
/>
</RailFrame>
),
};
/** The policy list section (above Tools). */
export const ListSection: Story = {
render: () => (
<div
style={{
width: RAIL_WIDTH,
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-lg)",
padding: "var(--space-2) 0",
}}
>
<PoliciesSection />
</div>
),
};
// The setup + edit wizard embeds the Watch Folders automation builder (its
// Workflow step), which needs the ToolWorkflow context - so the wizard is
// exercised in-app, not via an isolated story here.
@@ -1,179 +0,0 @@
import "fake-indexeddb/auto";
import type { ReactNode } from "react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
// The global setup mock returns the i18n KEY (t: (key) => key); these tests
// assert the rendered English copy, so override locally to return the default
// value passed to t() — mirroring i18next's runtime fallback when a key is
// missing. (Interpolated strings are returned raw; no test here asserts them.)
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string) =>
typeof defaultValue === "string" ? defaultValue : key,
i18n: { changeLanguage: vi.fn() },
}),
initReactI18next: { type: "3rdParty", init: vi.fn() },
I18nextProvider: ({ children }: { children: ReactNode }) => children,
}));
// Policies ship gated SaaS-only via the build-flavor flag; these tests exercise
// the component itself, so force the flag on regardless of the test build flavor.
vi.mock("@app/constants/featureFlags", () => ({ POLICIES_ENABLED: true }));
// usePolicies derives `canConfigure` from app-config; with no AppConfigProvider
// here `config` is null, which (tri-state gate) hides the edit affordances. Mock
// app-config as a single-user deployment (login off) so the local operator can
// configure and the narrative view's Edit/Pause/Delete actions render.
vi.mock("@app/contexts/AppConfigContext", async (orig) => ({
...(await orig<typeof import("@app/contexts/AppConfigContext")>()),
useAppConfig: () => ({ config: { enableLogin: false } }),
}));
// The shared Tooltip (used by the "what is a policy?" info button) pulls in
// preferences/sidebar contexts we don't set up here — passthrough it.
vi.mock("@app/components/shared/Tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => children,
}));
// The wizard's Workflow step embeds the Watch Folders automation builder, which
// needs the ToolWorkflow context. Mock it: a stub that wires the save trigger to
// hand back the seed automation, so the wizard's submit still completes.
vi.mock("@app/components/policies/PolicyWorkflowStep", () => ({
AutomationMode: { CREATE: "create", EDIT: "edit", SUGGESTED: "suggested" },
PolicyWorkflowStep: (props: {
automation: unknown;
saveTriggerRef: { current: (() => void) | null };
onComplete: (automation: unknown, toolRegistry: unknown) => void;
}) => {
props.saveTriggerRef.current = () => props.onComplete(props.automation, {});
return null;
},
}));
// The backend is the source of truth, but these UI tests run offline: list
// rejects (so the mount reconcile keeps the local cache), while save/delete
// resolve so the enable flow completes.
vi.mock("@app/services/policyApi", () => ({
listPolicies: vi.fn().mockRejectedValue(new Error("offline")),
savePolicy: vi.fn().mockImplementation(async (p: { id?: string }) => ({
...p,
id: p.id && p.id.length > 0 ? p.id : "be-test",
})),
getPolicy: vi.fn(),
deletePolicy: vi.fn().mockResolvedValue(undefined),
runStoredPolicy: vi.fn(),
runPolicyPipeline: vi.fn(),
getPolicyRun: vi.fn(),
}));
// Enabling a policy creates its backing Watched Folders WatchedFolder (IndexedDB);
// jsdom's crypto lacks randomUUID, which watchedFolderStorage uses for folder ids.
if (typeof globalThis.crypto?.randomUUID !== "function") {
const orig = globalThis.crypto;
vi.stubGlobal("crypto", {
getRandomValues: orig?.getRandomValues?.bind(orig),
randomUUID: () =>
`p-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`,
});
}
import {
PoliciesSection,
PolicyDetailTakeover,
usePolicyDetailActive,
} from "@app/components/policies/PoliciesSidebar";
import { resetPolicySelection } from "@app/components/policies/policySelectionStore";
/**
* Mirrors how RightSidebar swaps the policy list for the detail takeover: the
* list shows above Tools when nothing is open, the takeover replaces it when a
* policy is selected.
*/
function PoliciesHost() {
const active = usePolicyDetailActive();
return active ? <PolicyDetailTakeover /> : <PoliciesSection />;
}
function renderHost() {
return render(
<MantineProvider>
<PoliciesHost />
</MantineProvider>,
);
}
describe("Policies right-sidebar surface", () => {
beforeEach(() => {
localStorage.clear();
// Seed a configured Security policy (the only live category) in the local
// cache. The backend list is mocked to reject (offline), so the mount
// reconcile leaves it in place — giving the narrative tests a policy to open.
localStorage.setItem(
"stirling-policies-state",
JSON.stringify({
security: {
configured: true,
status: "active",
sources: ["editor"],
scopeTypes: [],
reviewerEmail: "",
fieldValues: {},
},
}),
);
resetPolicySelection();
});
it("renders the policy list with every category", () => {
renderHost();
expect(screen.getByText("Policies")).toBeInTheDocument();
for (const label of [
"Ingestion",
"Security",
"Compliance",
"Routing",
"Retention",
]) {
expect(screen.getByText(label)).toBeInTheDocument();
}
});
it("shows Security as active and the unbuilt categories as upgrade-gated", () => {
renderHost();
expect(screen.getAllByText("Active").length).toBeGreaterThanOrEqual(1);
// Ingestion, Compliance, Routing, Retention are locked for this release.
expect(screen.getAllByText("Upgrade to enterprise")).toHaveLength(4);
});
it("does not open an upgrade-gated policy when its row is clicked", () => {
renderHost();
fireEvent.click(screen.getByText("Ingestion"));
// The locked row isn't a button — we stay on the list, nothing opens.
expect(screen.getByText("Policies")).toBeInTheDocument();
expect(screen.queryByText("Enforces")).not.toBeInTheDocument();
});
it("opens the narrative view when a live policy is clicked", () => {
renderHost();
fireEvent.click(screen.getByText("Security"));
expect(screen.getByText("Enforces")).toBeInTheDocument();
expect(screen.getByText("Edit Settings")).toBeInTheDocument();
});
it("shows an honest empty activity feed when no files have been uploaded", async () => {
renderHost();
fireEvent.click(screen.getByText("Security"));
expect(screen.getByText("Recent Activity")).toBeInTheDocument();
// Activity is derived from real uploads; with none, the empty state shows.
expect(await screen.findByText("No activity yet")).toBeInTheDocument();
});
it("returns to the list via the close button", () => {
renderHost();
fireEvent.click(screen.getByText("Security"));
expect(screen.getByText("Enforces")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Close"));
expect(screen.getByText("Policies")).toBeInTheDocument();
});
});
@@ -1,927 +0,0 @@
/**
* Proprietary implementation of the right-sidebar Policies surface.
*
* Shadows the core stubs at {@code core/components/policies/PoliciesSidebar.tsx}
* via the {@code @app/*} alias cascade. Three slots, all driven by the shared
* {@link policySelectionStore} so they stay in sync:
* {@link PoliciesSection} the collapsible policy list, rendered above the
* Tools section in {@code RightSidebar} when no policy is open.
* {@link PolicyDetailTakeover} the detail / wizard / settings view, which
* replaces the Tools area when a policy is open.
* {@link PoliciesCollapsedButton} the icon rail shown when the sidebar is
* collapsed; clicking an icon selects the policy and expands the rail.
*/
import {
useState,
useEffect,
useMemo,
type DragEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { Menu } from "@mantine/core";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import ReplayRounded from "@mui/icons-material/ReplayRounded";
import DragIndicatorRounded from "@mui/icons-material/DragIndicatorRounded";
import MoreHorizRounded from "@mui/icons-material/MoreHorizRounded";
import TuneRounded from "@mui/icons-material/TuneRounded";
import InfoOutlined from "@mui/icons-material/InfoOutlined";
import { usePolicies } from "@app/hooks/usePolicies";
import { usePolicyCatalog } from "@app/hooks/usePolicyCatalog";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useAuth } from "@app/auth/UseSession";
import { getPolicyAutomation } from "@app/services/policyFolders";
import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
import {
runsToActivity,
runsToStats,
progressByCategory,
retryableFailedRuns,
EMPTY_RUN_PROGRESS,
} from "@app/services/policyLiveData";
import {
removeRun,
usePolicyRuns,
usePolicyWaveStart,
} from "@app/components/policies/policyRunStore";
import { runPolicyOnFile } from "@app/components/policies/usePolicyAutoRun";
import type { FileId } from "@app/types/file";
import type {
AutomationConfig,
AutomationOperation,
} from "@app/types/automation";
import type { WatchedFolder } from "@app/types/watchedFolders";
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { IconBadge } from "@app/ui/IconBadge";
import {
deriveRowStatus,
STATUS_LABEL,
ROW_ACCENT,
} from "@app/components/policies/policyStatus";
import { StatusBadge } from "@app/ui/StatusBadge";
import { SectionHeader } from "@app/ui/SectionHeader";
import { PolicySetupWizard } from "@app/components/policies/PolicySetupWizard";
import { PolicyDetailPanel } from "@app/components/policies/PolicyDetailPanel";
import { PolicyDeleteConfirmModal } from "@app/components/policies/PolicyDeleteConfirmModal";
import type { PolicyCategory, PolicyConfigResult } from "@app/types/policies";
import { PanelHeader } from "@app/ui/PanelHeader";
import {
usePolicySelection,
selectPolicy,
setPolicyDetailView,
closePolicy,
openPolicySettings,
closePolicySettings,
} from "@app/components/policies/policySelectionStore";
import "@app/components/policies/Policies.css";
/** localStorage key persisting the Policies section's expand/collapse state. */
const POLICIES_COLLAPSED_KEY = "stirling-policies-section-collapsed";
/** Whether the right rail should host the Policies section. True in proprietary. */
export function usePoliciesEnabled(): boolean {
return POLICIES_ENABLED;
}
/**
* Whether the right rail should show the Policies section.
*/
export function usePoliciesVisible(): boolean {
const pol = usePolicies();
const { categories } = usePolicyCatalog();
if (!POLICIES_ENABLED) return false;
return pol.canConfigure || categories.some((c) => !c.comingSoon);
}
/**
* Whether the current user is a guest who can't open or configure policies
* an anonymous user on a login-enabled deployment (i.e. a SaaS sign-up prompt
* candidate). The policy list stays visible but its rows don't open; the guest
* sign-up banner explains why. A login-disabled single-user deployment has an
* anonymous local operator with full access, so it is not gated.
*/
export function usePolicyGuestBlocked(): boolean {
const { config } = useAppConfig();
const { user } = useAuth();
return config?.enableLogin === true && user?.is_anonymous === true;
}
/** Re-summon the guest sign-up banner (the saas GuestUserBanner listens for this;
* a no-op on builds without it). Used when a guest clicks a gated policy. */
function promptGuestSignup(): void {
window.dispatchEvent(new CustomEvent("stirling:show-guest-banner"));
}
/**
* Whether a policy is open i.e. its detail view should take over the rail in
* place of the tool list. False when the feature is off or nothing is selected.
*/
export function usePolicyDetailActive(): boolean {
const { selectedId, settingsOpen } = usePolicySelection();
return POLICIES_ENABLED && (selectedId != null || settingsOpen);
}
/** The collapsible policy list, rendered above the Tools section. */
export function PoliciesSection({
leadingControl,
}: {
/** Optional control rendered to the left of the header (e.g. the sidebar
* collapse button), mirroring the back-button + title in a policy. */
leadingControl?: ReactNode;
} = {}) {
const { t } = useTranslation();
const pol = usePolicies();
const { categories } = usePolicyCatalog();
const guestBlocked = usePolicyGuestBlocked();
// Live run tallies drive the per-row processing ring + the header summary,
// scoped to the current upload wave so they don't accumulate across the whole
// persisted run history.
const runs = usePolicyRuns();
const waveStart = usePolicyWaveStart();
const progress = useMemo(
() => progressByCategory(runs, waveStart),
[runs, waveStart],
);
// Failed runs still worth retrying, across ALL policies — drives the single
// global "Retry failed policies" action. Deliberately not per-policy: a chain
// failure strands the file mid-pipeline whichever step failed, and retrying
// globally resumes every stranded file (successful steps are never re-run;
// completed runs chain onward automatically). NOT wave-scoped: failures from
// earlier uploads still count.
const retryableRuns = useMemo(() => retryableFailedRuns(runs), [runs]);
const retryAllFailed = () => {
// Same replace-in-place pattern as the queue-full auto-retry: drop the stale
// failed row, fire a fresh run. Queue-full rejections during the burst are
// absorbed by the existing backoff.
for (const failed of retryableRuns) {
const backendId = pol.policies[failed.categoryId]?.backendId;
if (!backendId) continue;
removeRun(failed.runId);
void runPolicyOnFile(
failed.categoryId,
backendId,
failed.fileId as FileId,
failed.fileName,
);
}
};
// Persist the expand/collapse state across refreshes.
const [expanded, setExpanded] = useState(() => {
try {
return localStorage.getItem(POLICIES_COLLAPSED_KEY) !== "1";
} catch {
return true;
}
});
const toggleExpanded = () =>
setExpanded((open) => {
const next = !open;
try {
localStorage.setItem(POLICIES_COLLAPSED_KEY, next ? "0" : "1");
} catch {
// Best-effort; ignore quota/availability failures.
}
return next;
});
if (!POLICIES_ENABLED) return null;
// Admins / team leads see the full catalogue (coming-soon rows greyed as an
// enterprise upsell); regular users only see the live policies — the
// coming-soon "Upgrade to enterprise" rows are hidden from them.
const visibleCategories = pol.canConfigure
? categories
: categories.filter((c) => !c.comingSoon);
if (visibleCategories.length === 0) return null;
// The header tally counts every CONFIGURED policy (active + paused), not just
// the active ones.
const configuredCount = categories.filter(
(c) => pol.policies[c.id]?.configured,
).length;
// Rows render in execution order (defaults to catalog order until reordered
// on the Policy settings page).
const displayCategories = [...visibleCategories].sort(
(a, b) =>
(pol.policies[a.id]?.order ?? 0) - (pol.policies[b.id]?.order ?? 0),
);
return (
<div className="pol-list">
<div className="pol-list-head">
{leadingControl}
<SectionHeader
title={t("policies.sidebar.title", "Policies")}
count={t("policies.sidebar.activeCount", "{{count}} active", {
count: configuredCount,
})}
collapsible
expanded={expanded}
onToggle={toggleExpanded}
/>
<Menu position="bottom-end" width="13rem" withinPortal>
<Menu.Target>
<ActionIcon
variant="quiet"
className="pol-info-btn"
aria-label={t(
"policies.sidebar.optionsAriaLabel",
"Policy options",
)}
>
<MoreHorizRounded sx={{ fontSize: "1.25rem" }} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{/* Hovering surfaces the same explanation the info tooltip used to show. */}
<AppTooltip
content={t(
"policies.sidebar.infoTooltip",
"A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps.",
)}
position="left"
maxWidth="16rem"
>
<Menu.Item
leftSection={<InfoOutlined sx={{ fontSize: "1rem" }} />}
>
{t("policies.sidebar.whatIsPolicy", "What is a policy?")}
</Menu.Item>
</AppTooltip>
{pol.canConfigure && (
<Menu.Item
leftSection={<TuneRounded sx={{ fontSize: "1rem" }} />}
onClick={() => openPolicySettings()}
>
{t("policies.sidebar.policySettings", "Policy settings")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</div>
{retryableRuns.length > 0 && !guestBlocked && (
<Button
variant="secondary"
size="sm"
fullWidth
className="pol-retry-failed"
onClick={retryAllFailed}
leftSection={<ReplayRounded sx={{ fontSize: "0.95rem" }} />}
>
{t(
"policies.sidebar.retryFailed",
"Retry failed policies ({{count}})",
{
count: retryableRuns.length,
},
)}
</Button>
)}
{expanded && (
<>
<div className="pol-list-rows">
{displayCategories.map((cat) => {
if (cat.comingSoon) {
return (
<div key={cat.id} className="pol-row pol-row--soon">
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
{cat.icon}
</IconBadge>
<span className="pol-row-label">
{t(`policies.catalog.${cat.id}`, cat.label)}
</span>
<span className="pol-row-trail">
<Button
variant="quiet"
size="sm"
fontSize="xs"
accent="neutral"
onClick={() =>
window.open(
"https://stirling.com/contact",
"_blank",
"noopener,noreferrer",
)
}
>
{t(
"policies.sidebar.upgradeToEnterprise",
"Upgrade to enterprise",
)}
</Button>
</span>
</div>
);
}
const status = deriveRowStatus(pol.policies[cat.id]);
const rowProgress = progress.get(cat.id) ?? EMPTY_RUN_PROGRESS;
const isProcessing = rowProgress.running > 0;
const icon = (
<span
className={`pol-row-icon${isProcessing ? " is-processing" : ""}`}
data-accent={ROW_ACCENT[cat.id] ?? "blue"}
>
{isProcessing && (
<span className="pol-row-ring" aria-hidden="true" />
)}
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
{cat.icon}
</IconBadge>
</span>
);
return (
<Button
key={cat.id}
type="button"
variant="tertiary"
hover={false}
fullWidth
justify="between"
className="pol-row"
leftSection={
rowProgress.total > 0 ? (
<AppTooltip
position="left"
content={t(
"policies.sidebar.rowProgress",
"{{completed}} of {{total}} files processed",
{
completed: rowProgress.completed,
total: rowProgress.total,
},
)}
>
{icon}
</AppTooltip>
) : (
icon
)
}
rightSection={
<span className="pol-row-trail">
{status === "setup" ? (
<span className="pol-row-setup">
{t("policies.sidebar.setUp", "Set up")}
</span>
) : (
<StatusBadge
tone={status === "active" ? "success" : "warning"}
size="sm"
>
{t(`policies.status.${status}`, STATUS_LABEL[status])}
</StatusBadge>
)}
<ChevronRightIcon
className="pol-row-chevron"
sx={{ fontSize: "1rem" }}
/>
</span>
}
onClick={() =>
guestBlocked ? promptGuestSignup() : selectPolicy(cat.id)
}
>
<span className="pol-row-label">
{t(`policies.catalog.${cat.id}`, cat.label)}
</span>
</Button>
);
})}
</div>
</>
)}
</div>
);
}
/**
* Takeover dispatcher: shows the policy-settings page (execution order), an open
* policy's detail, or nothing whichever the selection store currently holds.
* The heavy per-policy hooks live in {@link PolicyOpenDetail}, so the settings
* page doesn't pay for (or trip over) them.
*/
export function PolicyDetailTakeover() {
const { selectedId, settingsOpen } = usePolicySelection();
if (!POLICIES_ENABLED) return null;
if (settingsOpen && selectedId == null) return <PolicySettingsPanel />;
if (selectedId == null) return null;
return <PolicyOpenDetail />;
}
/**
* The open-policy view narrative detail, setup wizard, or edit-settings
* which replaces the Tools area while a policy is selected.
*/
function PolicyOpenDetail() {
const { t } = useTranslation();
const pol = usePolicies();
const { categories, configs, sources, docTypes } = usePolicyCatalog();
const { selectedId, detailView } = usePolicySelection();
// The configured policy's backing folder + automation (its real, editable
// pipeline). `reloadKey` bumps after the edit modal saves so the detail
// reflects the new steps. Falls back to the preset's rules when unconfigured.
const folderId = selectedId ? pol.policies[selectedId]?.folderId : undefined;
const [steps, setSteps] = useState<AutomationOperation[]>([]);
const [backingFolder, setBackingFolder] = useState<WatchedFolder | null>(
null,
);
const [backingAutomation, setBackingAutomation] =
useState<AutomationConfig | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const [confirmingDelete, setConfirmingDelete] = useState(false);
useEffect(() => {
if (!folderId) {
setSteps([]);
setBackingFolder(null);
setBackingAutomation(null);
return;
}
let cancelled = false;
void (async () => {
const [folder, automation] = await Promise.all([
watchedFolderStorage.getFolder(folderId),
getPolicyAutomation(folderId),
]);
if (cancelled) return;
setBackingFolder(folder);
setBackingAutomation(automation);
setSteps(automation?.operations ?? []);
})();
return () => {
cancelled = true;
};
}, [folderId, reloadKey]);
// Activity/stats come from the real backend runs the auto-run controller fires
// on every upload (policyRunStore), filtered to this policy's category. The
// store is reactive, so the feed updates live as runs progress — no polling
// here (the controller does the run-status polling).
const allRuns = usePolicyRuns();
const categoryRuns = useMemo(
() => allRuns.filter((r) => r.categoryId === selectedId),
[allRuns, selectedId],
);
if (!POLICIES_ENABLED || selectedId == null) return null;
const category = categories.find((c) => c.id === selectedId);
const state = pol.policies[selectedId];
const config = configs[selectedId];
if (!category || !state || !config) return null;
// Coming-soon categories can't be opened (the list row is locked anyway).
if (category.comingSoon) return null;
const status = deriveRowStatus(state);
const onSetupClassification = () => {
const classifier = categories.find((c) => c.providesClassification);
if (classifier) selectPolicy(classifier.id);
};
// Preset (tool-chain) policies configure via the wizard's locked tool-config
// step instead of the add/remove builder; the wizard fires onCommitConfig for
// them (and onComplete for builder-based categories). One commit path serves
// both first-time configure and edits.
const commitConfig = (result: PolicyConfigResult) =>
pol.commitPolicyConfig(selectedId, result).then(() => {
setReloadKey((k) => k + 1);
setPolicyDetailView("detail");
});
// Setup: the shared wizard in create mode.
if (!state.configured) {
return (
<PolicySetupWizard
key={selectedId}
category={category}
config={config}
initial={state}
sources={sources}
docTypes={docTypes}
canConfigure={pol.canConfigure}
// No standalone classification policy exists yet to enable doc-type
// narrowing, so it stays off for this release.
classificationEnabled={false}
mode="create"
onCancel={() => closePolicy()}
onComplete={(result) =>
pol
.enablePolicy(selectedId, result)
.then(() => setPolicyDetailView("detail"))
}
onCommitConfig={commitConfig}
onSetupClassification={onSetupClassification}
/>
);
}
// Edit: the same wizard in edit mode, pre-filled — so editing has the settings
// steps (not just the workflow). Wait for the backing automation to load so
// the workflow step edits the real pipeline.
if (detailView === "settings" && pol.canConfigure) {
if (!backingAutomation) {
return (
<div className="pol-detail">
<div className="pol-scroll">
<p className="pol-desc">
{t("policies.sidebar.loading", "Loading…")}
</p>
</div>
</div>
);
}
return (
<PolicySetupWizard
key={`edit-${selectedId}`}
category={category}
config={config}
initial={state}
sources={sources}
docTypes={docTypes}
canConfigure={pol.canConfigure}
classificationEnabled={false}
mode="edit"
existingAutomation={backingAutomation}
initialFolder={backingFolder ?? undefined}
onCancel={() => setPolicyDetailView("detail")}
onComplete={(result) =>
pol.savePolicyConfig(selectedId, result).then(() => {
setReloadKey((k) => k + 1);
setPolicyDetailView("detail");
})
}
onCommitConfig={commitConfig}
onSetupClassification={onSetupClassification}
/>
);
}
return (
<>
<PolicyDetailPanel
category={category}
config={config}
status={status}
steps={steps}
activity={runsToActivity(categoryRuns)}
stats={runsToStats(categoryRuns, backingFolder?.createdAt)}
canConfigure={pol.canConfigure}
canDelete={!state.isDefault}
onBack={() => closePolicy()}
onEditSettings={() => {
// Seeded/active policies may have no backing folder yet — create one
// from the preset so there's a workflow to edit, then open settings.
void pol
.ensurePolicyFolder(selectedId)
.then(() => setPolicyDetailView("settings"));
}}
onTogglePause={() =>
status === "paused"
? pol.resumePolicy(selectedId)
: pol.pausePolicy(selectedId)
}
onDelete={() => setConfirmingDelete(true)}
onRetry={(item) => {
if (item.fileId && state.backendId) {
void runPolicyOnFile(
selectedId,
state.backendId,
item.fileId as FileId,
item.doc,
);
}
}}
/>
{confirmingDelete && (
<PolicyDeleteConfirmModal
opened
label={category.label}
onCancel={() => setConfirmingDelete(false)}
onConfirm={() => {
setConfirmingDelete(false);
closePolicy();
void pol.deletePolicy(selectedId);
}}
/>
)}
</>
);
}
/**
* The Policy settings takeover reached from the section header's "…" menu.
* Each trigger (upload / export) gets its own run-order list, since a chain only
* spans policies that fire on the same trigger reordering one never affects the
* other. Admin-only (the menu entry is gated on canConfigure).
*/
function PolicySettingsPanel() {
const { t } = useTranslation();
const pol = usePolicies();
const { categories } = usePolicyCatalog();
// Configured, live policies for a trigger, in execution order.
const inOrder = (trigger: "upload" | "export") =>
categories
.filter(
(c) =>
pol.policies[c.id]?.configured &&
!c.comingSoon &&
(pol.policies[c.id]?.runOn ?? "upload") === trigger,
)
.sort(
(a, b) =>
(pol.policies[a.id]?.order ?? 0) - (pol.policies[b.id]?.order ?? 0),
);
const uploadCats = inOrder("upload");
const exportCats = inOrder("export");
// Order is one global sort key, so persist both groups together (upload first)
// to keep each group's members contiguous — the auto-run chain reads relative
// order within a trigger.
const persist = (uploadIds: string[], exportIds: string[]) =>
pol.reorderPolicies([...uploadIds, ...exportIds]);
return (
<div className="pol-detail">
<PanelHeader
icon={<TuneRounded sx={{ fontSize: "1.1rem" }} />}
title={t("policies.settings.title", "Policy settings")}
onClose={() => closePolicySettings()}
closeLabel={t("policies.detail.close", "Close")}
/>
<div className="pol-scroll">
<p className="pol-desc">
{t(
"policies.settings.runOrderDesc",
"When more than one policy runs on the same trigger, they run in this order — each on the previous policy's output. Drag to reorder.",
)}
</p>
{/* Both triggers are always shown so the run order for each is explicit,
with an empty note when a trigger has no policies. */}
<PolicyReorderSection
title={t("policies.settings.onUpload", "On upload")}
cats={uploadCats}
emptyText={t(
"policies.settings.noneUpload",
"No policies currently run on upload.",
)}
onReorder={(ids) =>
persist(
ids,
exportCats.map((c) => c.id),
)
}
/>
<PolicyReorderSection
title={t("policies.settings.onExport", "On export")}
cats={exportCats}
emptyText={t(
"policies.settings.noneExport",
"No policies currently run on export.",
)}
onReorder={(ids) =>
persist(
uploadCats.map((c) => c.id),
ids,
)
}
/>
</div>
</div>
);
}
/**
* One trigger's run-order list. Rows drag to reorder (only when there's more than
* one to order); the drag ghost is the whole row with a blue outline, and a
* straight blue line marks where the policy will land. Reorders in isolation and
* hands the new id order back to the parent to persist.
*/
function PolicyReorderSection({
title,
cats,
emptyText,
onReorder,
}: {
title: string;
cats: PolicyCategory[];
emptyText: string;
onReorder: (orderedIds: string[]) => void;
}) {
const { t } = useTranslation();
const [dragId, setDragId] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
// Whether the drop would land after (vs before) the hovered row.
const [overBelow, setOverBelow] = useState(false);
const draggable = cats.length >= 2;
const clear = () => {
setDragId(null);
setOverId(null);
};
const handleDrop = (targetId: string) => {
if (!dragId || dragId === targetId) return clear();
const ids = cats.map((c) => c.id);
const from = ids.indexOf(dragId);
let to = ids.indexOf(targetId) + (overBelow ? 1 : 0);
if (from < 0 || to < 0) return clear();
ids.splice(from, 1);
if (from < to) to -= 1;
ids.splice(to, 0, dragId);
onReorder(ids);
clear();
};
// The native drag image would be just the grip under the cursor; instead snapshot
// the whole row (a styled clone) so the ghost that follows the mouse is the full
// row with a blue outline.
const startDrag = (e: DragEvent<HTMLSpanElement>, catId: string) => {
setDragId(catId);
e.dataTransfer.effectAllowed = "move";
const row = (e.currentTarget as HTMLElement).closest(".pol-reorder-row");
if (row instanceof HTMLElement) {
const clone = row.cloneNode(true) as HTMLElement;
clone.classList.add("pol-reorder-row--ghost");
clone.style.width = `${row.offsetWidth}px`;
clone.style.position = "fixed";
clone.style.top = "-1000px";
clone.style.left = "-1000px";
clone.style.pointerEvents = "none";
document.body.appendChild(clone);
e.dataTransfer.setDragImage(clone, 24, row.offsetHeight / 2);
window.setTimeout(() => clone.remove(), 0);
}
};
if (cats.length === 0) {
return (
<section className="pol-reorder-section">
<p className="pol-section-label">{title}</p>
<p className="pol-reorder-empty">{emptyText}</p>
</section>
);
}
return (
<section className="pol-reorder-section">
<p className="pol-section-label">{title}</p>
<div className="pol-reorder-list">
{cats.map((cat) => (
<div
key={cat.id}
className="pol-reorder-row"
data-dragging={dragId === cat.id || undefined}
data-drop={
draggable && overId === cat.id && dragId !== cat.id
? overBelow
? "below"
: "above"
: undefined
}
onDragOver={
draggable
? (e) => {
if (!dragId) return;
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
setOverId(cat.id);
setOverBelow(e.clientY > rect.top + rect.height / 2);
}
: undefined
}
onDragLeave={
draggable
? () => setOverId((id) => (id === cat.id ? null : id))
: undefined
}
onDrop={
draggable
? (e) => {
e.preventDefault();
handleDrop(cat.id);
}
: undefined
}
>
{draggable && (
<span
className="pol-reorder-grip"
draggable
onDragStart={(e) => startDrag(e, cat.id)}
onDragEnd={clear}
role="button"
tabIndex={-1}
aria-label={t(
"policies.settings.reorderHandle",
"Drag to reorder",
)}
>
<DragIndicatorRounded sx={{ fontSize: "1rem" }} />
</span>
)}
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
{cat.icon}
</IconBadge>
<span className="pol-reorder-label">
{t(`policies.catalog.${cat.id}`, cat.label)}
</span>
</div>
))}
</div>
</section>
);
}
/**
* Collapsed-rail policy icons. Each tints blue when active and carries a small
* status dot (green active / amber paused). Clicking selects the policy and
* expands the rail.
*/
export function PoliciesCollapsedButton({
onExpand,
}: {
onExpand: () => void;
}) {
const { t } = useTranslation();
const pol = usePolicies();
const { categories } = usePolicyCatalog();
const guestBlocked = usePolicyGuestBlocked();
if (!POLICIES_ENABLED) return null;
// Coming-soon policies are excluded; admins see all real policies, others only see configured ones — renders nothing when empty.
const railCategories = categories.filter((cat) => {
if (cat.comingSoon) return false;
if (pol.canConfigure) return true;
return pol.policies[cat.id]?.configured;
});
if (railCategories.length === 0) return null;
return (
<>
<div className="pol-crail">
{railCategories.map((cat) => {
const status = deriveRowStatus(pol.policies[cat.id]);
const label = t(`policies.catalog.${cat.id}`, cat.label);
const statusLabel = t(
`policies.status.${status}`,
STATUS_LABEL[status],
);
const suffix =
status === "active"
? t("policies.sidebar.railSuffixActive", " (Active)")
: status === "paused"
? t("policies.sidebar.railSuffixPaused", " (Paused)")
: "";
return (
<AppTooltip
key={cat.id}
content={`${label}${suffix}`}
position="left"
arrow
delay={300}
>
<Button
type="button"
variant="tertiary"
hover={false}
className="pol-crail-btn"
data-status={status}
aria-label={t(
"policies.sidebar.railAriaLabel",
"{{label}} policy — {{status}}",
{ label, status: statusLabel },
)}
onClick={() => {
if (guestBlocked) {
promptGuestSignup();
return;
}
selectPolicy(cat.id);
onExpand();
}}
>
{cat.icon}
{(status === "active" || status === "paused") && (
<span className="pol-crail-dot" data-status={status} />
)}
</Button>
</AppTooltip>
);
})}
</div>
<div className="tool-panel__collapsed-divider" />
</>
);
}
@@ -1,49 +0,0 @@
import { Modal, Text, Stack, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui/Button";
interface PolicyDeleteConfirmModalProps {
opened: boolean;
/** The policy's display label (the category name). */
label: string;
onConfirm: () => void;
onCancel: () => void;
}
/** Confirm before deleting a policy — removing it discards its backing workflow. */
export function PolicyDeleteConfirmModal({
opened,
label,
onConfirm,
onCancel,
}: PolicyDeleteConfirmModalProps) {
const { t } = useTranslation();
return (
<Modal
opened={opened}
onClose={onCancel}
title={t("policies.deleteConfirmTitle", "Delete {{label}} policy?", {
label,
})}
centered
size="sm"
>
<Stack gap="md">
<Text size="sm">
{t(
"policies.deleteConfirmBody",
"This removes the policy and its workflow. Documents already processed are not affected.",
)}
</Text>
<Group gap="sm" justify="flex-end">
<Button variant="secondary" size="sm" onClick={onCancel}>
{t("cancel", "Cancel")}
</Button>
<Button accent="danger" size="sm" onClick={onConfirm}>
{t("delete", "Delete")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -1,338 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import PublicIcon from "@mui/icons-material/Public";
import ScheduleIcon from "@mui/icons-material/Schedule";
import HistoryIcon from "@mui/icons-material/History";
import DescriptionIcon from "@mui/icons-material/Description";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import AutorenewIcon from "@mui/icons-material/Autorenew";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import LockIcon from "@mui/icons-material/Lock";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import { PanelHeader } from "@app/ui/PanelHeader";
import { ROW_ACCENT } from "@app/components/policies/policyStatus";
import { Card } from "@app/ui/Card";
import { ChipFlow } from "@app/ui/ChipFlow";
import { StatusBadge } from "@app/ui/StatusBadge";
import { EmptyState } from "@app/ui/EmptyState";
import { Button } from "@app/ui/Button";
import { Banner } from "@app/ui/Banner";
import { ListRow } from "@app/ui/ListRow";
import type {
PolicyActivityItem,
PolicyCategory,
PolicyConfigDef,
PolicyRowStatus,
PolicyStats,
} from "@app/types/policies";
import type { AutomationOperation } from "@app/types/automation";
interface PolicyDetailPanelProps {
category: PolicyCategory;
config: PolicyConfigDef;
/** Derived display status. */
status: PolicyRowStatus;
/**
* The policy's real configured steps (from its backing automation). When
* present these drive the Enforces flow; otherwise the preset's decorative
* `rules` are shown (e.g. before configuration).
*/
steps?: AutomationOperation[];
/** Activity feed derived from the user's files; empty until files exist. */
activity?: PolicyActivityItem[];
/** Summary stats derived from the user's files. */
stats?: PolicyStats;
canConfigure: boolean;
/** Default (built-in) policies aren't deletable, so the Delete action hides. */
canDelete: boolean;
onBack: () => void;
onEditSettings: () => void;
onTogglePause: () => void;
onDelete: () => void;
/** Re-run a failed activity item's policy on its file. */
onRetry?: (item: PolicyActivityItem) => void;
}
/** "addWatermark" → "Add Watermark" — a light humanisation of op ids for display. */
function humanizeOperation(op: string): string {
return op
.replace(/([A-Z])/g, " $1")
.replace(/^./, (c) => c.toUpperCase())
.trim();
}
/**
* A failed run's error in the activity feed. Backend errors can be long (or
* multi-line stack traces) and would otherwise blow up the row, so anything
* lengthy is clamped and collapsed by default with a Show more/less toggle.
* Short messages (e.g. "Enforcement failed") render plainly with no toggle.
*/
function ActivityError({
message,
t,
}: {
message: string;
t: (key: string, defaultValue: string) => string;
}) {
const [expanded, setExpanded] = useState(false);
const needsToggle = message.length > 80 || message.includes("\n");
if (!needsToggle) return <>{message}</>;
return (
<span className="pol-activity-error">
<span
className={`pol-activity-error__text${expanded ? "" : " pol-activity-error__text--clamped"}`}
>
{message}
</span>
<Button
variant="tertiary"
className="pol-activity-error__toggle"
onClick={() => setExpanded((v) => !v)}
>
{expanded
? t("policies.detail.showLess", "Show less")
: t("policies.detail.showMore", "Show more")}
</Button>
</span>
);
}
/** Narrative view for a configured policy (Enforces / Activity / Stats). */
export function PolicyDetailPanel({
category,
config,
status,
steps,
activity,
stats,
canConfigure,
canDelete,
onBack,
onEditSettings,
onTogglePause,
onDelete,
onRetry,
}: PolicyDetailPanelProps) {
const { t } = useTranslation();
const isPaused = status === "paused";
const [activityOpen, setActivityOpen] = useState(true);
// Real configured steps drive the flow; fall back to the preset's rule labels.
const enforceItems =
steps && steps.length > 0
? steps.map((s) => humanizeOperation(s.operation))
: config.rules;
// Activity + stats are derived from the user's real files; until they load (or
// if none exist) show an honest empty feed / zeroed stats.
const activityItems = activity ?? [];
const statValues = stats ?? {
enforced: 0,
dataProcessed: "0 B",
activeFor: "—",
};
return (
<div className="pol-detail">
<PanelHeader
icon={category.icon}
accent={ROW_ACCENT[category.id]}
title={t(`policies.catalog.${category.id}`, category.label)}
onClose={onBack}
closeLabel={t("policies.detail.close", "Close")}
actions={
<StatusBadge
tone={isPaused ? "warning" : "success"}
showDot
pulse={!isPaused}
>
{isPaused
? t("policies.detail.statusPaused", "Paused")
: t("policies.detail.statusActive", "Active")}
</StatusBadge>
}
/>
<div className="pol-scroll">
{/* Enforces */}
<div>
<p className="pol-section-label">
{t("policies.detail.enforces", "Enforces")}
</p>
<Card padding="default">
<div className="pol-rule-flow">
<ChipFlow items={enforceItems} separator="arrow" />
</div>
<div className="pol-meta-row">
<span className="pol-meta-item">
<PublicIcon sx={{ fontSize: "0.8rem" }} />
{config.scopeLabel}
</span>
<span className="pol-meta-item">
<ScheduleIcon sx={{ fontSize: "0.8rem" }} />
{t("policies.detail.onEveryUpload", "On every upload")}
</span>
</div>
<div className="pol-note">
<HistoryIcon sx={{ fontSize: "0.8rem" }} />
{t(
"policies.detail.originalsNote",
"Originals stay untouched • Enforced version saved alongside",
)}
</div>
</Card>
</div>
{/* Recent Activity */}
<div>
<Button
variant="quiet"
fullWidth
justify="between"
className="pol-section-label pol-section-toggle"
onClick={() => setActivityOpen((o) => !o)}
aria-expanded={activityOpen}
rightSection={
<KeyboardArrowDownIcon
className={`pol-section-chevron${activityOpen ? " is-open" : ""}`}
fontSize="small"
/>
}
>
<span>
{t("policies.detail.recentActivity", "Recent Activity")}
</span>
</Button>
{activityOpen &&
(activityItems.length > 0 ? (
<Card padding="none">
<div className="pol-activity-list">
{activityItems.map((item, i) => (
<ListRow
key={item.runId ?? `${item.doc}-${item.time}`}
divider={i > 0}
leadingTone={
item.status === "flagged"
? "warning"
: item.status === "processing"
? "info"
: "success"
}
leading={
item.status === "flagged" ? (
<WarningAmberIcon sx={{ fontSize: "0.85rem" }} />
) : item.status === "processing" ? (
<AutorenewIcon
className="pol-spin"
sx={{ fontSize: "0.85rem" }}
/>
) : (
<CheckCircleIcon sx={{ fontSize: "0.85rem" }} />
)
}
title={item.doc}
description={
item.status === "flagged" ? (
<ActivityError message={item.action} t={t} />
) : (
item.action
)
}
meta={item.time}
trailing={
item.status === "flagged" && onRetry ? (
<Button
variant="tertiary"
size="sm"
onClick={() => onRetry(item)}
>
{t("policies.detail.retry", "Retry")}
</Button>
) : undefined
}
/>
))}
</div>
</Card>
) : (
<Card padding="default">
<EmptyState
size="compact"
icon={<DescriptionIcon sx={{ fontSize: "1.5rem" }} />}
title={t(
"policies.detail.noActivityTitle",
"No activity yet",
)}
description={t(
"policies.detail.noActivityDescription",
"Documents will appear here once this policy runs.",
)}
/>
</Card>
))}
</div>
{/* Stats one grouped card with divided columns, intentionally
unlabelled for a quiet summary footer. */}
<Card padding="none">
<div className="pol-stats">
<div className="pol-stat">
<span className="pol-stat-value">
{statValues.enforced.toLocaleString()}
</span>
<span className="pol-stat-label">
{t("policies.detail.statDocsEnforced", "Docs enforced")}
</span>
</div>
<div className="pol-stat">
<span className="pol-stat-value">{statValues.dataProcessed}</span>
<span className="pol-stat-label">
{t("policies.detail.statDataProcessed", "Data processed")}
</span>
</div>
<div className="pol-stat">
<span className="pol-stat-value">{statValues.activeFor}</span>
<span className="pol-stat-label">
{t("policies.detail.statActive", "Active")}
</span>
</div>
</div>
</Card>
{!canConfigure && (
<Banner
tone="neutral"
icon={<LockIcon sx={{ fontSize: "1rem" }} />}
description={t(
"policies.detail.managedByOrg",
"Managed by your organization. Contact a team leader to change this policy.",
)}
/>
)}
</div>
{canConfigure && (
<div className={`pol-footer${canDelete ? "" : " pol-footer-end"}`}>
{canDelete && (
<Button
variant="tertiary"
accent="danger"
size="sm"
leftSection={<DeleteOutlineIcon sx={{ fontSize: "0.9rem" }} />}
onClick={onDelete}
style={{ marginRight: "auto" }}
>
{t("delete", "Delete")}
</Button>
)}
<Button variant="secondary" size="sm" onClick={onTogglePause}>
{isPaused
? t("policies.detail.resume", "Resume")
: t("policies.detail.pause", "Pause")}
</Button>
<Button size="sm" onClick={onEditSettings}>
{t("policies.detail.editSettings", "Edit Settings")}
</Button>
</div>
)}
</div>
);
}
@@ -1,96 +0,0 @@
import { useTranslation } from "react-i18next";
import { ToggleSwitch } from "@app/ui/ToggleSwitch";
import { Select } from "@app/ui/Select";
import { Input } from "@app/ui/Input";
import { Chip } from "@app/ui/Chip";
import { SettingsRow } from "@app/ui/SettingsRow";
import type { PolicyField } from "@app/types/policies";
interface PolicyFieldRowProps {
field: PolicyField;
/** Effective current value (override or definition default). */
value: boolean | string | string[];
onChange: (value: boolean | string | string[]) => void;
/** First row in a group omits the top divider. */
first?: boolean;
}
/**
* Renders one policy setting: toggle, select, multi-select chips, or text.
* Controlled the parent owns the value. Uses SUI controls (ToggleSwitch /
* Select / Input / Chip) so it matches the rest of the policy surface.
*/
export function PolicyFieldRow({
field,
value,
onChange,
first,
}: PolicyFieldRowProps) {
const { t } = useTranslation();
// Field labels and option labels come from the policy catalog data, so they're
// wrapped at the render site with data-keyed ids (English stays the fallback).
const fieldLabel = t(`policies.field.${field.key}`, field.label);
if (field.type === "chips") {
const selected = Array.isArray(value) ? value : [];
const toggle = (opt: string) =>
onChange(
selected.includes(opt)
? selected.filter((o) => o !== opt)
: [...selected, opt],
);
return (
<div className="pol-field" data-first={first || undefined}>
<div className="pol-field-chips-head">
<span className="pol-field-label">{fieldLabel}</span>
<span className="pol-field-count">
{t("policies.fields.selectedCount", "{{count}} selected", {
count: selected.length,
})}
</span>
</div>
<div className="pol-field-chips">
{(field.options ?? []).map((opt) => (
<Chip key={opt} size="sm" onClick={() => toggle(opt)}>
{t(`policies.fieldOption.${field.key}.${opt}`, opt)}
</Chip>
))}
</div>
</div>
);
}
const control =
field.type === "toggle" ? (
<ToggleSwitch
size="sm"
checked={Boolean(value)}
onChange={(checked) => onChange(checked)}
aria-label={fieldLabel}
/>
) : field.type === "select" ? (
<Select
inputSize="sm"
options={(field.options ?? []).map((o) => ({
value: o,
label: t(`policies.fieldOption.${field.key}.${o}`, o),
}))}
value={typeof value === "string" ? value : ""}
onChange={(value) => onChange(value ?? "")}
aria-label={fieldLabel}
/>
) : (
<Input
inputSize="sm"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
aria-label={fieldLabel}
/>
);
return (
<div className="pol-field" data-first={first || undefined}>
<SettingsRow label={fieldLabel} control={control} />
</div>
);
}
@@ -1,737 +0,0 @@
import { useState, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { PanelHeader } from "@app/ui/PanelHeader";
import { ROW_ACCENT } from "@app/components/policies/policyStatus";
import { Card } from "@app/ui/Card";
import { Button } from "@app/ui/Button";
import { Input } from "@app/ui/Input";
import { Select } from "@app/ui/Select";
import { SettingsRow } from "@app/ui/SettingsRow";
import { Checkbox } from "@app/ui/Checkbox";
import { Banner } from "@app/ui/Banner";
import { EmptyState } from "@app/ui/EmptyState";
import { StepIndicator } from "@app/ui/StepIndicator";
import type {
PolicyCategory,
PolicyConfigDef,
PolicyConfigResult,
PolicySource,
PolicyState,
PolicyWizardResult,
} from "@app/types/policies";
import type {
AutomationConfig,
AutomationOperation,
} from "@app/types/automation";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import type { WatchedFolder } from "@app/types/watchedFolders";
import { buildPipelineDefinition } from "@app/services/policyPipeline";
import { useAuth } from "@app/auth/UseSession";
import { PolicyFieldRow } from "@app/components/policies/PolicyFieldRow";
import { resolveFieldValues } from "@app/components/policies/policyValues";
import {
PolicyWorkflowStep,
AutomationMode,
} from "@app/components/policies/PolicyWorkflowStep";
import { PolicyToolConfigStep } from "@app/components/policies/PolicyToolConfigStep";
import { getPolicyToolChain } from "@app/components/policies/policyToolChains";
import { ClassificationLabelsSection } from "@app/components/policies/ClassificationLabelsSection";
// Sources are always "editor" for this release, so the Sources step is dropped
// from the flow (its panel code is kept below for when other sources return).
const SOURCES_IN_FLOW = false;
const TOTAL_STEPS = SOURCES_IN_FLOW ? 3 : 2;
interface PolicySetupWizardProps {
category: PolicyCategory;
config: PolicyConfigDef;
initial: PolicyState;
/** Sources a policy can run over (catalog-supplied). */
sources: PolicySource[];
/** Document types scope can be narrowed to (catalog-supplied). */
docTypes: string[];
canConfigure: boolean;
/** Whether the Classification (ingestion) policy is active — gates doc-type narrowing. */
classificationEnabled: boolean;
/** "create" seeds the workflow from the preset; "edit" loads the backing automation. */
mode?: "create" | "edit";
/** The backing automation to edit (edit mode). */
existingAutomation?: AutomationConfig;
/** The backing folder, to pre-fill output + retry settings (edit mode). */
initialFolder?: WatchedFolder;
onCancel: () => void;
/**
* Fires on submit with the saved workflow + collected settings. May be async;
* if the returned promise rejects, the wizard re-enables submit and surfaces
* the failure rather than hanging on a permanently-disabled button.
*/
onComplete: (result: PolicyWizardResult) => void | Promise<void>;
/**
* For preset (tool-chain) policies whose Workflow step is the locked tool
* config: fires instead of `onComplete`, carrying the configured tools as
* operations + mapped pipeline steps. When absent the wizard uses the
* add/remove builder + `onComplete`.
*/
onCommitConfig?: (result: PolicyConfigResult) => void | Promise<void>;
onSetupClassification: () => void;
}
/**
* The shared policy wizard, used for both setup and edit. Two steps: Workflow
* (the tool pipeline, reusing the Watch Folders builder) Settings (the policy
* fields + output/retry config). The workflow builder is kept mounted across
* steps so the final action can trigger its save.
*/
export function PolicySetupWizard({
category,
config,
initial,
sources: sourceDefs,
docTypes,
canConfigure,
classificationEnabled,
mode = "create",
existingAutomation,
initialFolder,
onCancel,
onComplete,
onCommitConfig,
onSetupClassification,
}: PolicySetupWizardProps) {
const { t } = useTranslation();
const isEdit = mode === "edit";
// Preset (tool-chain) policies render the locked tool config as their Workflow
// step instead of the add/remove builder.
const toolChain = getPolicyToolChain(category.id);
// A single-tool chain has nothing to toggle/configure, so its config UI is
// hidden (kept mounted so the submit trigger still emits that one tool).
const singleToolChain = toolChain != null && toolChain.length === 1;
const { user } = useAuth();
const [step, setStep] = useState(1);
const [fieldValues, setFieldValues] = useState(() =>
resolveFieldValues(config, initial),
);
const [sources, setSources] = useState<string[]>(
initial.sources.length ? initial.sources : ["editor"],
);
const [scopeNarrow, setScopeNarrow] = useState(initial.scopeTypes.length > 0);
const [scopeTypes, setScopeTypes] = useState<string[]>(initial.scopeTypes);
// Reviewer isn't shown in the flow; the field is still saved on the policy,
// defaulted to the signed-in user.
const reviewerEmail = initial.reviewerEmail || user?.email || "";
// Output + retry settings — the real, working folder settings (the engine
// applies them). Pre-filled from the backing folder in edit mode.
const [outputMode, setOutputMode] = useState<"new_file" | "new_version">(
initialFolder?.outputMode ?? "new_version",
);
const [outputName, setOutputName] = useState(initialFolder?.outputName ?? "");
const [outputNamePosition, setOutputNamePosition] = useState<
"prefix" | "suffix" | "auto-number"
>(initialFolder?.outputNamePosition ?? "prefix");
const [maxRetries, setMaxRetries] = useState(initialFolder?.maxRetries ?? 3);
const [retryDelayMinutes, setRetryDelayMinutes] = useState(
initialFolder?.retryDelayMinutes ?? 5,
);
// The editor event this policy runs on: input on upload, or output on export.
const [runOn, setRunOn] = useState<"upload" | "export">(
initial.runOn ?? "upload",
);
const workflowSave = useRef<(() => void) | null>(null);
const [submitting, setSubmitting] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// Seed the workflow builder: the backing automation in edit, else a synthetic
// config carrying the category preset's operations (created on save).
const seedAutomation = useMemo<AutomationConfig>(
() =>
existingAutomation ?? {
id: "",
name: `${category.label} Policy`,
description: `${category.label} policy workflow`,
icon: "WorkIcon",
operations: config.defaultOperations,
createdAt: "",
updatedAt: "",
},
[existingAutomation, category.label, config.defaultOperations],
);
if (!canConfigure) {
return (
<div className="pol-detail">
<PanelHeader
icon={category.icon}
accent={ROW_ACCENT[category.id]}
title={
isEdit
? t("policies.wizard.editTitle", "Edit {{label}} Policy", {
label: t(`policies.catalog.${category.id}`, category.label),
})
: t("policies.wizard.setupTitle", "Set up {{label}} Policy", {
label: t(`policies.catalog.${category.id}`, category.label),
})
}
onClose={onCancel}
closeLabel={t("policies.wizard.close", "Close")}
/>
<div className="pol-scroll">
<EmptyState
title={t(
"policies.wizard.lockedTitle",
"Managed by your organization",
)}
description={t(
"policies.wizard.lockedDescription",
"Contact a team leader to change this policy.",
)}
/>
</div>
</div>
);
}
const back = () =>
step > 1 ? setStep((s) => Math.max(1, s - 1)) : onCancel();
const toggleSource = (id: string) =>
setSources((prev) =>
prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id],
);
// Once the builder persists the workflow, map its operations to backend
// endpoint paths (the registry only lives in the Workflow step) and hand the
// automation + built steps + settings to the host (which closes the wizard on
// success). If the host's async save rejects, recover so the submit button
// doesn't stay disabled forever.
const handleWorkflowSaved = (
automation: AutomationConfig,
toolRegistry: Partial<ToolRegistry>,
) => {
const { definition, unresolved } = buildPipelineDefinition(
automation,
toolRegistry,
);
Promise.resolve(
onComplete({
automation,
fieldValues,
sources,
scopeTypes: scopeNarrow ? scopeTypes : [],
reviewerEmail,
folder: {
runOn,
outputMode,
outputName: outputName.trim(),
outputNamePosition,
maxRetries,
retryDelayMinutes,
},
pipelineSteps: definition.steps,
unresolvedOps: unresolved,
}),
).catch(() => {
setSubmitting(false);
setSaveError(
t(
"policies.wizard.saveError",
"Couldn't save the policy. Please try again.",
),
);
});
};
// Tool-chain policies: the locked config step emits its enabled tools as
// operations + mapped steps; hand them to the host's commit path.
const handleToolConfigSaved = (
operations: AutomationOperation[],
pipelineSteps: { operation: string; parameters: Record<string, unknown> }[],
unresolvedOps: string[],
) => {
Promise.resolve(
onCommitConfig?.({
operations,
pipelineSteps,
unresolvedOps,
fieldValues,
sources,
scopeTypes: scopeNarrow ? scopeTypes : [],
reviewerEmail,
folder: {
runOn,
outputMode,
outputName: outputName.trim(),
outputNamePosition,
maxRetries,
retryDelayMinutes,
},
}),
).catch(() => {
setSubmitting(false);
setSaveError(
t(
"policies.wizard.saveError",
"Couldn't save the policy. Please try again.",
),
);
});
};
// Final submit: guard against double-submit (the step stays mounted, so a
// second click would persist twice), then trigger the step's save.
const submit = () => {
if (submitting) return;
setSaveError(null);
setSubmitting(true);
workflowSave.current?.();
};
// The builder couldn't save (e.g. no configured tools) — surface it and send
// the user back to the Workflow step to fix it.
const handleSaveFailed = () => {
setSubmitting(false);
setSaveError(
t(
"policies.wizard.noToolsError",
"Add at least one configured tool to the workflow first.",
),
);
setStep(1);
};
return (
<div className="pol-detail">
<PanelHeader
icon={category.icon}
accent={ROW_ACCENT[category.id]}
title={
isEdit
? t("policies.wizard.editTitle", "Edit {{label}} Policy", {
label: t(`policies.catalog.${category.id}`, category.label),
})
: t("policies.wizard.setupTitle", "Set up {{label}} Policy", {
label: t(`policies.catalog.${category.id}`, category.label),
})
}
onClose={onCancel}
closeLabel={t("cancel", "Cancel")}
/>
<div className="pol-steps">
<span className="pol-step-label">
{t("policies.wizard.stepOf", "Step {{step}} of {{total}}", {
step,
total: TOTAL_STEPS,
})}
</span>
<StepIndicator total={TOTAL_STEPS} current={step} />
</div>
<div className="pol-scroll">
{saveError && (
<Banner
tone="danger"
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
description={saveError}
/>
)}
{/* Step 1 Workflow. Kept mounted (hidden on other steps) so the final
submit can trigger its save. Preset (tool-chain) policies show the
locked, per-tool config; the rest show the add/remove builder. */}
<div style={{ display: step === 1 ? undefined : "none" }}>
{toolChain ? (
<>
{/* Single-tool chains have nothing to configure hide the prompt
and the toggle, but keep the step mounted (display:none) so the
final submit still emits that one tool. */}
{!singleToolChain && (
<p className="pol-desc">
{t(
"policies.wizard.toolChainDesc",
"Configure the tools this policy runs on each document.",
)}
</p>
)}
<div style={{ display: singleToolChain ? "none" : undefined }}>
<PolicyToolConfigStep
chainIds={toolChain}
initialOperations={
existingAutomation?.operations ?? config.defaultOperations
}
presetOperations={config.defaultOperations}
categoryLabel={category.label}
saveTriggerRef={workflowSave}
onComplete={handleToolConfigSaved}
/>
</div>
</>
) : (
<>
<p className="pol-desc">
{t(
"policies.wizard.builderDesc",
"Build the sequence of tools this policy runs on each document.",
)}
</p>
<PolicyWorkflowStep
automation={seedAutomation}
mode={isEdit ? AutomationMode.EDIT : AutomationMode.SUGGESTED}
saveTriggerRef={workflowSave}
onComplete={handleWorkflowSaved}
onSaveFailed={handleSaveFailed}
/>
</>
)}
{/* The Classification policy owns the editable label sets (team-shared
+ personal) the classifier picks from. Kept on the first step
alongside the tool so it's not buried. */}
{category.id === "classification" && (
<ClassificationLabelsSection canConfigure={canConfigure} />
)}
</div>
{step === 2 && (
<>
<p className="pol-desc">{category.desc}</p>
{config.fields.length > 0 && (
<Card padding="none">
{config.fields.map((f, i) => (
<PolicyFieldRow
key={f.key}
field={f}
value={fieldValues[f.key]}
first={i === 0}
onChange={(v) =>
setFieldValues((prev) => ({ ...prev, [f.key]: v }))
}
/>
))}
</Card>
)}
{/* Real, working output + retry settings (applied by the engine). */}
<p className="pol-section-label">
{t("policies.wizard.outputRetriesLabel", "Output & retries")}
</p>
<Card padding="none">
{/* The editor event the policy runs on: input on upload, or
output on export (enforced before the file is exported). */}
<div className="pol-subhead">
{t("policies.wizard.runOnSubhead", "Run on")}
</div>
<div className="pol-field" data-first>
<SettingsRow
label={t("policies.wizard.runOnLabel", "Run on")}
control={
<Select
inputSize="sm"
value={runOn}
onChange={(value) =>
setRunOn((value ?? "upload") as "upload" | "export")
}
aria-label={t("policies.wizard.runOnLabel", "Run on")}
options={[
{
value: "upload",
label: t("policies.wizard.runOnUpload", "Upload"),
},
{
value: "export",
label: t("policies.wizard.runOnExport", "Export"),
},
]}
/>
}
/>
</div>
<div className="pol-subhead">
{t("policies.wizard.outputSubhead", "Output")}
</div>
<div className="pol-field" data-first>
<SettingsRow
label={t("policies.wizard.outputAsLabel", "Output as")}
control={
<Select
inputSize="sm"
value={outputMode}
onChange={(value) => {
const mode = (value ?? "new_file") as
| "new_file"
| "new_version";
setOutputMode(mode);
// Auto-number only applies to new files; a new version
// replaces the file in place, so fall back to suffix.
if (
mode === "new_version" &&
outputNamePosition === "auto-number"
) {
setOutputNamePosition("suffix");
}
}}
aria-label={t(
"policies.wizard.outputModeAria",
"Output mode",
)}
options={[
{
value: "new_file",
label: t("policies.wizard.outputNewFile", "New file"),
},
{
value: "new_version",
label: t(
"policies.wizard.outputNewVersion",
"New version",
),
},
]}
/>
}
/>
</div>
{/* Output filename: position + custom text together as one row. */}
<div className="pol-subhead">
{t("policies.wizard.outputFilenameSubhead", "Output filename")}
</div>
<div className="pol-field" data-first>
<div className="pol-name-row">
<Select
inputSize="sm"
value={outputNamePosition}
onChange={(value) =>
setOutputNamePosition(
(value ?? "suffix") as
| "prefix"
| "suffix"
| "auto-number",
)
}
aria-label={t(
"policies.wizard.filenamePositionAria",
"Filename position",
)}
options={[
{
value: "prefix",
label: t("policies.wizard.filenamePrefix", "Prefix"),
},
{
value: "suffix",
label: t("policies.wizard.filenameSuffix", "Suffix"),
},
// Auto-number only makes sense for separate new files.
...(outputMode === "new_file"
? [
{
value: "auto-number",
label: t(
"policies.wizard.filenameAutoNumber",
"Auto-number",
),
},
]
: []),
]}
/>
{/* Auto-number names the file itself, so there's no custom
text to add only show the input for prefix/suffix. */}
{outputNamePosition !== "auto-number" && (
<Input
inputSize="sm"
value={outputName}
onChange={(e) => setOutputName(e.target.value)}
placeholder={t(
"policies.wizard.filenameTextPlaceholder",
"Text to add (optional)",
)}
aria-label={t(
"policies.wizard.filenameTextAria",
"Filename text",
)}
/>
)}
</div>
</div>
<div className="pol-field">
<SettingsRow
label={t("policies.wizard.maxRetriesLabel", "Max retries")}
control={
<Input
type="number"
inputSize="sm"
value={String(maxRetries)}
onChange={(e) =>
setMaxRetries(Math.max(0, Number(e.target.value) || 0))
}
aria-label={t(
"policies.wizard.maxRetriesLabel",
"Max retries",
)}
/>
}
/>
</div>
<div className="pol-field">
<SettingsRow
label={t(
"policies.wizard.retryDelayLabel",
"Retry delay (min)",
)}
control={
<Input
type="number"
inputSize="sm"
value={String(retryDelayMinutes)}
onChange={(e) =>
setRetryDelayMinutes(
Math.max(0, Number(e.target.value) || 0),
)
}
aria-label={t(
"policies.wizard.retryDelayAria",
"Retry delay minutes",
)}
/>
}
/>
</div>
</Card>
</>
)}
{/* Sources step kept in code, out of the flow for this release
(SOURCES_IN_FLOW), since sources are always "editor" for now. */}
{SOURCES_IN_FLOW && step === 3 && (
<>
<p className="pol-desc">
{t(
"policies.wizard.sourcesDesc",
"Choose where this policy runs and which document types it applies to.",
)}
</p>
<p className="pol-section-label">
{t("policies.wizard.sourcesLabel", "Sources")}
</p>
<Card padding="none">
{sourceDefs.map((src, i) => (
<div
key={src.id}
className="pol-source"
data-first={i === 0 || undefined}
>
<Checkbox
checked={sources.includes(src.id)}
onChange={() => toggleSource(src.id)}
leadingIcon={src.icon}
label={src.label}
description={src.desc}
/>
</div>
))}
</Card>
<p className="pol-section-label">
{t("policies.wizard.docTypesLabel", "Document types")}
</p>
{!classificationEnabled ? (
<Banner
tone="warning"
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
title={t(
"policies.wizard.allDocTypesTitle",
"All document types",
)}
description={t(
"policies.wizard.allDocTypesDescription",
"Enable the Classification policy to filter by document type.",
)}
action={
<Button
variant="tertiary"
size="sm"
onClick={onSetupClassification}
>
{t(
"policies.wizard.setupClassification",
"Set up Classification",
)}
</Button>
}
/>
) : (
<Card padding="none">
<div className="pol-doctypes-head">
<span className="pol-field-label">
{scopeTypes.length === 0
? t(
"policies.wizard.allDocTypesTitle",
"All document types",
)
: t(
"policies.wizard.typesSelected",
"{{count}} types selected",
{ count: scopeTypes.length },
)}
</span>
<Button
variant="tertiary"
className="pol-link"
onClick={() => setScopeNarrow((v) => !v)}
>
{scopeNarrow
? t("policies.wizard.clear", "Clear")
: t("policies.wizard.edit", "Edit")}
</Button>
</div>
{scopeNarrow && (
<div className="pol-doctypes">
{docTypes.map((dt) => (
<Checkbox
key={dt}
checked={scopeTypes.includes(dt)}
onChange={() =>
setScopeTypes((prev) =>
prev.includes(dt)
? prev.filter((d) => d !== dt)
: [...prev, dt],
)
}
label={t(`policies.docType.${dt}`, dt)}
/>
))}
</div>
)}
</Card>
)}
</>
)}
</div>
<div className="pol-footer">
<Button variant="tertiary" size="sm" onClick={back}>
{step > 1 ? t("policies.wizard.back", "Back") : t("cancel", "Cancel")}
</Button>
{step < TOTAL_STEPS ? (
<Button
size="sm"
style={{ marginLeft: "auto" }}
onClick={() => setStep((s) => Math.min(TOTAL_STEPS, s + 1))}
>
{t("policies.wizard.continue", "Continue")}
</Button>
) : (
<Button
size="sm"
style={{ marginLeft: "auto" }}
onClick={submit}
disabled={submitting}
>
{isEdit
? t("policies.wizard.saveChanges", "Save Changes")
: t("policies.wizard.enablePolicy", "Enable Policy")}
</Button>
)}
</div>
</div>
);
}
@@ -1,165 +0,0 @@
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { Loader } from "@mantine/core";
import { ToggleSwitch } from "@app/ui/ToggleSwitch";
import { ActionIcon } from "@app/ui/ActionIcon";
import { Card } from "@app/ui/Card";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import type { ToolId } from "@app/types/toolId";
/**
* Plain-language, non-technical descriptions shown by each tool's info button.
* Stored as [i18n key, English default] pairs so they can be resolved with `t`
* at render (the map lives at module scope, outside the component).
*/
const TOOL_PLAIN_INFO: Record<string, readonly [key: string, en: string]> = {
redact: [
"policies.toolConfig.info.redact",
"Automatically finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read in the document.",
],
sanitize: [
"policies.toolConfig.info.sanitize",
"Removes hidden JavaScript from the file, so nothing can run automatically when someone opens it.",
],
watermark: [
"policies.toolConfig.info.watermark",
"Stamps a visible mark (e.g. “Confidential”) across every page.",
],
};
/** One tool in a policy's fixed chain: whether it runs + its configured params. */
export interface PolicyToolState {
/** Frontend tool-registry id (also the registry key + the thing we map to an endpoint). */
operation: string;
/** Whether this tool runs as part of the policy (the per-tool on/off). */
enabled: boolean;
/** Tool-specific parameters (the shape its endpoint accepts). */
parameters: Record<string, unknown>;
}
interface PolicyToolConfigProps {
/** The policy's fixed tool chain — locked (no add/remove), only configurable. */
tools: PolicyToolState[];
toolRegistry: Partial<ToolRegistry>;
onChange: (tools: PolicyToolState[]) => void;
/** Read-only when the policy is managed / the user can't configure. */
editable?: boolean;
}
/**
* Locked, configure-only tool panel for a policy. The chain is fixed (you can't
* add or remove tools); each tool is a section that renders its OWN settings form
* from the tool registry (`automationSettings`) the same forms the automation
* builder uses so the config is generated from the tools in the workflow, not
* hardcoded per policy. The parameters produced here are exactly what the backend
* engine POSTs to each tool's endpoint.
*/
export function PolicyToolConfig({
tools,
toolRegistry,
onChange,
editable = true,
}: PolicyToolConfigProps) {
const { t } = useTranslation();
const patchTool = (index: number, patch: Partial<PolicyToolState>) =>
onChange(tools.map((t, i) => (i === index ? { ...t, ...patch } : t)));
return (
<div className="pol-tool-config">
{tools.map((tool, index) => {
const entry = toolRegistry[tool.operation as ToolId];
const Settings = entry?.automationSettings ?? null;
const toolName = entry?.name ?? tool.operation;
const plainInfo = TOOL_PLAIN_INFO[tool.operation];
return (
<Card key={tool.operation} padding="none">
<div className="pol-tool-head">
<span className="pol-tool-icon">{entry?.icon}</span>
<span className="pol-tool-name">{toolName}</span>
{plainInfo && (
<AppTooltip
content={t(plainInfo[0], plainInfo[1])}
sidebarTooltip
pinOnClick
>
<ActionIcon
type="button"
variant="tertiary"
className="pol-info-btn"
aria-label={t(
"policies.toolConfig.infoAriaLabel",
"What does {{tool}} do?",
{ tool: toolName },
)}
>
<LocalIcon
icon="info-outline-rounded"
width="1rem"
height="1rem"
style={{ color: "var(--icon-files-color)" }}
/>
</ActionIcon>
</AppTooltip>
)}
<ToggleSwitch
size="sm"
checked={tool.enabled}
disabled={!editable}
onChange={(checked) => patchTool(index, { enabled: checked })}
aria-label={t(
"policies.toolConfig.enableAriaLabel",
"Enable {{tool}}",
{
tool: toolName,
},
)}
/>
</div>
{tool.enabled &&
(tool.operation === "redact" ? (
<div className="pol-tool-body">
<PolicyRedactConfig
parameters={tool.parameters}
onChange={(parameters) => patchTool(index, { parameters })}
disabled={!editable}
/>
</div>
) : tool.operation === "sanitize" ? (
// Sanitize is config-less: it only removes JavaScript (params
// are fixed in the policy preset), so no settings are shown.
<></>
) : tool.operation === "watermark" ? (
// Watermark settings with flatten hidden + forced on (see
// PolicyWatermarkConfig).
<div className="pol-tool-body">
<PolicyWatermarkConfig
parameters={tool.parameters}
onChange={(parameters) => patchTool(index, { parameters })}
disabled={!editable}
/>
</div>
) : Settings ? (
<div className="pol-tool-body">
<Suspense fallback={<Loader size="sm" />}>
<Settings
parameters={tool.parameters}
onParameterChange={(key: string, value: unknown) =>
patchTool(index, {
parameters: { ...tool.parameters, [key]: value },
})
}
disabled={!editable}
/>
</Suspense>
</div>
) : null)}
</Card>
);
})}
</div>
);
}
@@ -1,114 +0,0 @@
import { useState, useEffect, type MutableRefObject } from "react";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { buildPipelineDefinition } from "@app/services/policyPipeline";
import {
PolicyToolConfig,
type PolicyToolState,
} from "@app/components/policies/PolicyToolConfig";
import type { ToolId } from "@app/types/toolId";
import type { AutomationOperation } from "@app/types/automation";
/**
* Seed a tool's parameters: start from the tool's own registry defaults, overlay
* the preset's defaults (e.g. the PII patterns), then apply only the saved
* values the user actually changed from the tool default. This means a policy
* saved while a param was at its default (an empty redact list) still inherits
* the preset value, while genuine user edits are preserved.
*/
function seedToolParameters(
registryDefaults: Record<string, unknown>,
presetParams: Record<string, unknown>,
savedParams: Record<string, unknown>,
): Record<string, unknown> {
const merged: Record<string, unknown> = {
...registryDefaults,
...presetParams,
};
const eq = (a: unknown, b: unknown) =>
JSON.stringify(a) === JSON.stringify(b);
for (const [key, value] of Object.entries(savedParams)) {
if (!eq(value, registryDefaults[key])) merged[key] = value;
}
return merged;
}
interface PolicyToolConfigStepProps {
/** The fixed, configurable tool chain (locked set) for this policy. */
chainIds: string[];
/** Operations to seed enabled/params from (saved ops, or preset defaults). */
initialOperations: AutomationOperation[];
/**
* The preset's default operations. Their params seed any tool whose saved
* value is still at the tool's own default so e.g. a policy saved with the
* PII list at its default inherits the preset patterns rather than running empty.
*/
presetOperations: AutomationOperation[];
/** Used to name the built pipeline definition. */
categoryLabel: string;
/** The wizard triggers this on its final submit (mirrors PolicyWorkflowStep). */
saveTriggerRef: MutableRefObject<(() => void) | null>;
/** Emits the enabled tools as operations + the endpoint-mapped backend steps. */
onComplete: (
operations: AutomationOperation[],
pipelineSteps: { operation: string; parameters: Record<string, unknown> }[],
unresolvedOps: string[],
) => void;
}
/**
* The wizard's Workflow step for preset (tool-chain) policies: the locked,
* per-tool config ({@link PolicyToolConfig}) instead of the add/remove builder.
* Isolates the ToolWorkflow dependency (so it's mockable in the rail tests) and
* wires the wizard's submit trigger to emit the configured tools as operations
* + the endpoint-mapped pipeline steps.
*/
export function PolicyToolConfigStep({
chainIds,
initialOperations,
presetOperations,
categoryLabel,
saveTriggerRef,
onComplete,
}: PolicyToolConfigStepProps) {
const { toolRegistry } = useToolWorkflow();
const [tools, setTools] = useState<PolicyToolState[]>(() =>
chainIds.map((op) => {
const saved = initialOperations.find((o) => o.operation === op);
const preset = presetOperations.find((o) => o.operation === op);
const defaults = (toolRegistry[op as ToolId]?.operationConfig
?.defaultParameters ?? {}) as Record<string, unknown>;
return {
operation: op,
enabled: Boolean(saved),
parameters: seedToolParameters(
defaults,
(preset?.parameters ?? {}) as Record<string, unknown>,
(saved?.parameters ?? {}) as Record<string, unknown>,
),
};
}),
);
// Re-wire the submit trigger whenever the tools change so it emits the latest.
useEffect(() => {
saveTriggerRef.current = () => {
const operations: AutomationOperation[] = tools
.filter((t) => t.enabled)
.map((t) => ({ operation: t.operation, parameters: t.parameters }));
const { definition, unresolved } = buildPipelineDefinition(
{ name: `${categoryLabel} Policy`, operations },
toolRegistry,
);
onComplete(operations, definition.steps, unresolved);
};
}, [tools, toolRegistry, categoryLabel, saveTriggerRef, onComplete]);
return (
<PolicyToolConfig
tools={tools}
toolRegistry={toolRegistry}
onChange={setTools}
/>
);
}
@@ -1,61 +0,0 @@
import type { MutableRefObject } from "react";
import AutomationCreation from "@app/components/tools/automate/AutomationCreation";
import { AutomationMode } from "@app/types/automation";
import type { AutomationConfig } from "@app/types/automation";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
interface PolicyWorkflowStepProps {
/**
* The automation to seed/edit. For setup this is a synthetic config carrying
* the category preset's operations; for edit it's the policy's backing
* automation.
*/
automation: AutomationConfig;
/** SUGGESTED seeds-then-creates (setup); EDIT updates in place (settings). */
mode: AutomationMode;
/** The host (wizard) triggers the builder's save imperatively from its footer. */
saveTriggerRef: MutableRefObject<(() => void) | null>;
/**
* Called with the saved automation once the builder persists it, plus the tool
* registry (which lives here) so the wizard can map operations to backend
* endpoint paths without depending on the ToolWorkflow context itself.
*/
onComplete: (
automation: AutomationConfig,
toolRegistry: Partial<ToolRegistry>,
) => void;
/** Called when save is triggered but the workflow isn't in a saveable state. */
onSaveFailed?: () => void;
}
/**
* The policy wizard's "Workflow" step: the Watch Folders automation builder
* ({@link AutomationCreation}) reused to define a policy's tool pipeline. Kept
* as its own component so the heavy builder + its ToolWorkflow dependency are
* isolated (and mockable in the rail tests).
*/
export function PolicyWorkflowStep({
automation,
mode,
saveTriggerRef,
onComplete,
onSaveFailed,
}: PolicyWorkflowStepProps) {
const { toolRegistry } = useToolWorkflow();
return (
<AutomationCreation
mode={mode}
existingAutomation={automation}
toolRegistry={toolRegistry}
hideMetadata
nameOverride={automation.name}
saveTriggerRef={saveTriggerRef}
onBack={() => {}}
onComplete={(saved) => onComplete(saved, toolRegistry)}
onSaveFailed={onSaveFailed}
/>
);
}
export { AutomationMode };
@@ -1,84 +1,35 @@
# Policies (frontend)
# Policies (editor)
Automation-backed document-enforcement policies — conceptually like Watch
Folders but backend-driven, with non-folder triggers (editor save/export,
device sweeps, cloud connectors). **This is the frontend only**: per-policy
state is persisted locally (localStorage) and activity + stats are derived from
your real uploaded files; server persistence + real enforcement land in a
follow-up. It ships behind the `POLICIES_ENABLED` feature flag (proprietary
build = on while in development, core build = off).
Automation-backed document-enforcement policies. The editor side is
**enforcement only**: policies are configured in the admin portal
(`src/portal/views/Policies.tsx`); the editor runs enabled policies on
uploaded files, blocks the file's exit points while a run is in flight, and
badges files a policy has produced. It ships behind the `POLICIES_ENABLED`
feature flag (SaaS build = on; proprietary and core builds = off; desktop
additionally requires an active SaaS connection).
## Layout
| Path | Role |
|------|------|
| `types/policies.ts` | Type model (category, fields, state). |
| `data/policyDefinitions.tsx` | Static preset definitions for the catalog: 5 categories (with the `providesClassification` data flag), per-category config fields, sources, doc types, and each category's default tool pipeline. Read it through `policyCatalog`, not directly. |
| `services/policyCatalog.ts` | **The definitions seam.** `loadPolicyCatalog()` returns categories/configs/sources/doc-types. Components reach definitions only through here (via `usePolicyCatalog`) — swap this one function for a backend fetch to go live without touching a component. |
| `hooks/usePolicyCatalog.ts` | Hook over the catalog seam (memoised; where loading/error state lands when it becomes async). |
| `services/policyStorage.ts` | Local persistence (localStorage) of per-policy **state** + change events. Swap this layer for the real API. |
| `hooks/usePolicies.ts` | State + lifecycle actions + permission flag. |
| `services/policyLiveData.ts` | Derives the detail view's activity feed + stats from the user's real uploaded files. |
| `components/policies/PoliciesSidebar.tsx` | The three right-rail slots: list section, detail takeover, collapsed-rail icons (+ `usePoliciesEnabled` / `usePolicyDetailActive`). Shadows the core stub. |
| `components/policies/policySelectionStore.ts` | Shared selected-policy / detail-view store the three slots sync through. |
| `components/policies/PolicySetupWizard.tsx` | 3-step setup (operations → sources/types → reviewer/confirm). |
| `components/policies/PolicyDetailPanel.tsx` | Configured "narrative" view (Enforces / Activity / Stats). |
| `components/policies/PolicySettingsForm.tsx` | Edit-settings sub-view. |
| `components/policies/PolicyFieldRow.tsx` | toggle / select / chips / text field renderer (SUI `SettingsRow` + `ToggleSwitch`/`Select`/`Input`/`Chip`). |
| `data/policyDefinitions.tsx` | Static preset definitions for the catalog. Read through `services/policyCatalog.ts` (`loadPolicyCatalog()`), not directly. |
| `services/policyStorage.ts` | Local persistence (localStorage) of per-policy state + change events. |
| `hooks/usePolicies.ts` | Policy state + permission flag, consumed by the auto-run controller. |
| `hooks/usePolicyFileBadges.ts` | Per-file badge map (which policies produced/are enforcing a file) — drives the shared `PolicyBadges` row and the exit-point blocking. |
| `components/policies/usePoliciesEnabled.ts` | The single build/connection gate for mounting the auto-run controller. Core stub = false; desktop shadow adds the SaaS-connection check. |
| `components/policies/PolicyAutoRunController.tsx` | Headless: enforces enabled policies on every uploaded file. Mounted by `RightSidebar`. |
| `components/policies/usePolicyAutoRun.ts` | The auto-run engine: dispatch, polling, retry, output import, server reconcile. |
| `components/policies/policyRunStore.ts` | `useSyncExternalStore` store of run records (status, progress, outputs), persisted to localStorage. |
| `components/policies/enforcementQueue.ts` | Export-time enforcement queue used by `services/policyExport.ts`. |
| `components/policies/policyStatus.ts` | Category → accent-colour mapping shared by badges and export toasts. |
The core build gets a no-op stub at `core/components/policies/PoliciesSidebar.tsx`;
`RightSidebar` (core) consumes the seam, so the section appears only in
proprietary builds.
## Design system (SUI + Mantine)
The surface is composed almost entirely from the shared SUI design system
(`@app/ui`), mixed with Mantine only where SUI has no equivalent.
SUI components used here: `PanelHeader` (+ leading `IconBadge`), `Card`,
`Button`, `Chip`, `ChipFlow`, `StatusBadge`, `Banner`, `EmptyState`,
`MetricCard`, `Input`, `Select`, `ToggleSwitch`, `Checkbox`, `FormField`,
`NavItem` (status `accent`), `ListRow`, `DataRow`, `SectionHeader`,
`StepIndicator`. Several of those (`IconBadge`, `ListRow`, `DataRow`,
`SectionHeader`, `StepIndicator`, `ChipFlow`, `SettingsRow`, plus the `NavItem`
accent / `PanelHeader` icon slot / `Checkbox` leadingIcon / `MetricCard size`)
were **built up in SUI** as part of this work — each has a Storybook story.
Bootstrapping: the editor loads `@app/tokens/tokens.css` globally via
`ThemeProvider`, which also mirrors the Mantine colour scheme onto
`<html data-theme>` (SUI's dark palette keys on `data-theme`).
The bespoke `.pol-*` CSS in `Policies.css` is now only thin layout scaffolding
(detail/scroll/footer wrappers, the collapsed rail, row insets that match SUI
`ListRow`); spacing snaps to the SUI `--space-*` scale and colour to the SUI
token set.
**Status-colour convention (locked):** blue = accent/identity (NavItem accent
bar, rail icon, detail Card accent — the prototype's blue); green `success`
StatusBadge = the "Active" pill/dot everywhere (list + detail + rail dot);
amber = paused. Configured rows render as raised cards (surface + border).
## Faithful to the prototype
5 categories (Ingestion, Security, Compliance, Routing, Retention), their full
field sets, the 3-step wizard (incl. the doc-type step gated behind the
Classification/ingestion policy), the configured narrative view (Enforces /
recent-activity feed / three-up stats), settings, the permission model
(owner/admin/member + solo), and the
docked right-sidebar placement: a collapsible **Policies** list above Tools, a
detail view that takes over the rail when a policy is open, and a collapsed-rail
of policy icons with active/paused status dots.
## Deviations / follow-ups
- **Billing upgrade flows.** The prototype's free → pay-as-you-go → enterprise
upgrade/commit/bespoke modals live in the Settings billing tab — a
billing-integration surface (Stripe/org state that doesn't exist yet),
deferred. The in-rail surface shows only the spend-limit warning chip, as in
the prototype's policy section.
- **Backend.** All persistence, enforcement, activity, and stats are mock. To
go live, replace `services/policyStorage.ts` and feed real activity/stats.
Enforcement UI lives with the surfaces it gates: `PolicyEnforcementOverlay`
(proprietary viewer), `PolicyEnforcingOverlay` (thumbnails + viewer overlay
body), and the shared `PolicyBadges` row (`core/components/shared/`).
## Tests
`services/policyStorage.test.ts` (seed/update/reset/heal/events) and
`data/policyDefinitions.test.ts` (permission matrix + definition integrity).
`policyRunStore.test.ts`, `usePolicyAutoRun.test.ts` (+ `.retry` / `.import`
variants), `hooks/usePolicyFileBadges.test.ts`,
`services/policyStorage.test.ts`, and `data/policyDefinitions.test.ts`.
@@ -1,92 +0,0 @@
/**
* Tiny external store for the currently-selected policy and its detail sub-view.
*
* The Policies surface is split across two slots in the right tool sidebar the
* list section (above Tools) and the detail takeover (which replaces Tools when a
* policy is open) plus the collapsed-rail icons. They live in different parts of
* {@code RightSidebar}'s tree, so selection can't be component-local useState.
* This module-level store (read via {@code useSyncExternalStore}) lets all three
* stay in sync without threading a context through the core sidebar.
*/
import { useSyncExternalStore } from "react";
import type { PolicyDetailView } from "@app/types/policies";
interface PolicySelection {
selectedId: string | null;
detailView: PolicyDetailView;
/** The policy-settings page (execution order) takes over the rail. Independent
* of {@link selectedId} it's a section-level view, not tied to one policy. */
settingsOpen: boolean;
}
let state: PolicySelection = {
selectedId: null,
detailView: "detail",
settingsOpen: false,
};
const listeners = new Set<() => void>();
function emit() {
for (const l of listeners) l();
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): PolicySelection {
return state;
}
/** Deterministic initial snapshot for SSR/hydration (never the mutable store). */
const SERVER_SNAPSHOT: PolicySelection = {
selectedId: null,
detailView: "detail",
settingsOpen: false,
};
function getServerSnapshot(): PolicySelection {
return SERVER_SNAPSHOT;
}
/** Open a policy's detail (resets the sub-view to the narrative). */
export function selectPolicy(id: string | null) {
state = { selectedId: id, detailView: "detail", settingsOpen: false };
emit();
}
/** Switch the open policy between its narrative and edit-settings sub-views. */
export function setPolicyDetailView(view: PolicyDetailView) {
if (state.detailView === view) return;
state = { ...state, detailView: view };
emit();
}
/** Open the policy-settings page (execution order). Clears any open policy. */
export function openPolicySettings() {
state = { selectedId: null, detailView: "detail", settingsOpen: true };
emit();
}
/** Close the policy-settings page and return to the list. */
export function closePolicySettings() {
if (!state.settingsOpen) return;
state = { ...state, settingsOpen: false };
emit();
}
/** Close the open policy and return to the list. */
export function closePolicy() {
selectPolicy(null);
}
/** Reset to the initial state — used by tests to isolate the module store. */
export function resetPolicySelection() {
state = { selectedId: null, detailView: "detail", settingsOpen: false };
emit();
}
export function usePolicySelection(): PolicySelection {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
@@ -1,21 +1,4 @@
import type { IconBadgeAccent } from "@app/ui/IconBadge";
import type { PolicyRowStatus, PolicyState } from "@app/types/policies";
/** Derive a single row/detail status from a policy's persisted state. */
export function deriveRowStatus(
state: PolicyState | undefined,
): PolicyRowStatus {
if (!state?.configured) return "setup";
if (state.status === "paused") return "paused";
return "active";
}
/** Human label for each row status. */
export const STATUS_LABEL: Record<PolicyRowStatus, string> = {
active: "Active",
paused: "Paused",
setup: "Set up",
};
/** Per-category icon accent — neutral (no tint background) across all categories. */
export const ROW_ACCENT: Record<string, IconBadgeAccent> = {
@@ -1,16 +0,0 @@
import type { PolicyConfigDef, PolicyState } from "@app/types/policies";
/**
* Resolve each field's effective value for a policy: the saved override from
* state, falling back to the definition's default.
*/
export function resolveFieldValues(
config: PolicyConfigDef,
state: PolicyState,
): Record<string, boolean | string | string[]> {
const out: Record<string, boolean | string | string[]> = {};
for (const f of config.fields) {
out[f.key] = state.fieldValues[f.key] ?? f.value;
}
return out;
}
@@ -0,0 +1,10 @@
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
/**
* Whether policy enforcement is active for this build. Gates mounting the
* headless PolicyAutoRunController. Shadows the core stub; the desktop build
* shadows this again to additionally require an active SaaS connection.
*/
export function usePoliciesEnabled(): boolean {
return POLICIES_ENABLED;
}

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