diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 9ed2610f51..d2f0472510 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -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 diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3Clients.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3Clients.java index eacdaf32c0..d3aab54c76 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3Clients.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3Clients.java @@ -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); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/LenientEncryptedStringConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/LenientEncryptedStringConverter.java new file mode 100644 index 0000000000..7bb6190220 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/LenientEncryptedStringConverter.java @@ -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 { + + @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; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 9494317751..045698c015 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -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 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 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 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> 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 trigger(@PathVariable String policyId) { Policy policy = policyStore .get(policyId) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java index 5a35b879c0..7731d936bd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java @@ -44,7 +44,7 @@ public class PolicyRunner { private final ProcessedLedger processedLedger; /** Full-listing sweep: resolve every source, then reconcile the ledger. */ - public List 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 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 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. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicySweep.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicySweep.java index 5302bacdb3..469b5f769e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicySweep.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicySweep.java @@ -86,4 +86,32 @@ final class PolicySweep implements ResolveContext { synchronized Set 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 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())); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/SweepOutcome.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/SweepOutcome.java new file mode 100644 index 0000000000..752299976d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/SweepOutcome.java @@ -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 runIds, int filesListed, int alreadyProcessed, int parked, int inFlight) { + + public SweepOutcome { + runIds = runIds == null ? List.of() : List.copyOf(runIds); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java new file mode 100644 index 0000000000..99e189f326 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java @@ -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 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 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 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 listObjects(S3Client client, S3Config config) { + List 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); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java index ed3cf707ad..52da21208a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java @@ -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 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; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/OutputNames.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/OutputNames.java new file mode 100644 index 0000000000..e82cc66b28 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/OutputNames.java @@ -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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java new file mode 100644 index 0000000000..c7d740868a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java @@ -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 deliver( + OutputDelivery delivery, List outputs, OutputSpec spec) throws IOException { + S3Config config = S3Config.from(spec.options()); + S3Client client = connectionPool.clientFor(config); + + List 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); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java new file mode 100644 index 0000000000..152a6de4cf --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java @@ -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 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 + + "]"; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionPool.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionPool.java new file mode 100644 index 0000000000..4118557dbd --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionPool.java @@ -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 clientFactory; + private final Map clients = new ConcurrentHashMap<>(); + + @Autowired + public S3ConnectionPool(ApplicationProperties applicationProperties) { + this(applicationProperties, S3ConnectionPool::buildClient); + } + + /** Factory-injecting constructor for tests. */ + public S3ConnectionPool( + ApplicationProperties applicationProperties, + Function 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(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Identities.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Identities.java new file mode 100644 index 0000000000..ae2d279b9f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Identities.java @@ -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()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java index 27d60703de..508e33a335 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java @@ -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 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 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 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(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceEntity.java index 412f862f89..c94e4cb079 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceEntity.java @@ -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; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java index 6e5f92ce44..af7ec3b11b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java @@ -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 configRows(Source source) { - return source.options().entrySet().stream() + Map 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(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java index 08e6bd4fbe..e393e47833 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java @@ -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; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java index 3e747d7169..32ba4ec77d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java @@ -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); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java b/app/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java index a3975de194..5a9619c2dd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java @@ -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 restoreRedacted( + Map incoming, Map stored) { + if (incoming == null || stored == null) { + return incoming; + } + Map merged = new LinkedHashMap<>(incoming); + merged.replaceAll( + (key, value) -> + REDACTED.equals(value) && stored.containsKey(key) + ? stored.get(key) + : value); + return merged; + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/LenientEncryptedStringConverterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/LenientEncryptedStringConverterTest.java new file mode 100644 index 0000000000..b61ded9708 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/LenientEncryptedStringConverterTest.java @@ -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(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index f38263ad3e..5d8961a46e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -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 response = + controller.savePolicy(s3OutputPolicy("p1", SecretMasker.REDACTED)); + + ArgumentCaptor 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> response = controller.trigger("a"); + ResponseEntity response = controller.trigger("a"); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); - assertThat(response.getBody()).containsExactly("run-a", "run-b"); + assertThat(response.getBody()).isEqualTo(outcome); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java index 13189b2481..ef4c0300da 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java @@ -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"); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceMinioTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceMinioTest.java new file mode 100644 index 0000000000..4e1e1fc305 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceMinioTest.java @@ -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 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 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 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 wrongSecret = new HashMap<>(baseOptions()); + wrongSecret.put("secretAccessKey", "not-the-secret"); + assertThatThrownBy(() -> source.validate(new InputSpec("s3", wrongSecret))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot access"); + + Map 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 baseOptions() { + return Map.of( + "bucket", bucket, + "endpoint", minio.getS3URL(), + "accessKeyId", ACCESS_KEY, + "secretAccessKey", SECRET_KEY); + } + + private InputSpec spec(Map extra) { + Map 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 present = new ArrayList<>(); + + @Override + public boolean claim(String identity, String gate, Supplier 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 identities) { + present.addAll(identities); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceTest.java new file mode 100644 index 0000000000..73995248dd --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceTest.java @@ -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 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 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 mine = source.resolve(spec(), ctx); + List 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 first = source.resolve(spec, ctx); + first.get(0).onComplete().accept(true); + List 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 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 options(Map extra) { + Map 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 present = new ArrayList<>(); + + private RecordingContext() { + this(POLICY); + } + + private RecordingContext(String policyId) { + this.policyId = policyId; + } + + @Override + public boolean claim(String identity, String gate, Supplier 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 identities) { + present.addAll(identities); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkMinioTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkMinioTest.java new file mode 100644 index 0000000000..a2a5a41ca0 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkMinioTest.java @@ -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 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 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 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 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 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 identities) {} + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkTest.java new file mode 100644 index 0000000000..8240bcc4e1 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkTest.java @@ -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 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 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 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 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 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(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java index d1d62b7dbe..e71deaf00a 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java @@ -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); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/ScheduleTriggerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/ScheduleTriggerTest.java index 889c50ee44..3f81392285 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/ScheduleTriggerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/ScheduleTriggerTest.java @@ -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 diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java index d91aec2029..415117a446 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java @@ -14,8 +14,8 @@ import org.junit.jupiter.api.Test; * Unit tests for {@link SecretMasker}. * *

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 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 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 stored = + Map.of("secretAccessKey", "shh", "accessKeyId", "AKIAEXAMPLE"); + Map incoming = + Map.of( + "secretAccessKey", SecretMasker.REDACTED, + "accessKeyId", "AKIA-NEW", + "bucket", "inbox"); + + Map 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 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 input = + Map.of( + "secretAccessKey", "shh", + "accessKeyId", "AKIAEXAMPLE"); + + Map 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 result = SecretMasker.mask(input); Map outer = (Map) result.get("outer"); - assertEquals("***REDACTED***", outer.get("jwt")); + assertEquals(SecretMasker.REDACTED, outer.get("jwt")); Map inner = (Map) 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 first = (Map) list.get(0); - assertEquals("***REDACTED***", first.get("token")); + assertEquals(SecretMasker.REDACTED, first.get("token")); Map second = (Map) list.get(1); assertEquals("john", second.get("username")); assertEquals("stringValue", list.get(2)); @@ -170,7 +213,8 @@ class SecretMaskerTest { Map outer = (Map) 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"); } } } diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java index 57f7e6a3a3..d67a0fddbf 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java @@ -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 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 startTrial(Authentication auth) { + public ResponseEntity 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 parseLineItems(String json) { diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java index f97d66f365..3a5d3db902 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java @@ -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; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java index dd635a2cf6..aa94128172 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java @@ -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; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/QuoteDetails.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/QuoteDetails.java new file mode 100644 index 0000000000..18683c5907 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/QuoteDetails.java @@ -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) {} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java index d10f216e8a..61dc5f499a 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java @@ -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) { diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java index 4707c94197..08ade5a157 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java @@ -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) { diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java index 10ffde5271..05b136f539 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java @@ -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 lineItems, long annualNetMinor, long tcvMinor, String currency) {} + List lineItems, + long annualNetMinor, + long tcvMinor, + long renewalAnnualNetMinor, + String currency) {} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java index cc6612c530..7511f2ff95 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java @@ -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; } } diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java index 225e0327e8..cae1cf36fb 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java @@ -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. + * + *

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 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 accepted 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. diff --git a/app/saas/src/main/resources/db/migration/saas/V33__procurement_invoice_pdf.sql b/app/saas/src/main/resources/db/migration/saas/V33__procurement_invoice_pdf.sql new file mode 100644 index 0000000000..946b8415c6 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V33__procurement_invoice_pdf.sql @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V34__procurement_deal_setup.sql b/app/saas/src/main/resources/db/migration/saas/V34__procurement_deal_setup.sql new file mode 100644 index 0000000000..186e180cb2 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V34__procurement_deal_setup.sql @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V35__procurement_renewal.sql b/app/saas/src/main/resources/db/migration/saas/V35__procurement_renewal.sql new file mode 100644 index 0000000000..75a4479c31 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V35__procurement_renewal.sql @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V36__procurement_size_mult.sql b/app/saas/src/main/resources/db/migration/saas/V36__procurement_size_mult.sql new file mode 100644 index 0000000000..7c213cb3dc --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V36__procurement_size_mult.sql @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V37__procurement_quote_billing_details.sql b/app/saas/src/main/resources/db/migration/saas/V37__procurement_quote_billing_details.sql new file mode 100644 index 0000000000..3f07f201bb --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V37__procurement_quote_billing_details.sql @@ -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); diff --git a/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java b/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java index 1676541db2..e62c6ff00b 100644 --- a/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java @@ -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(); diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index edea92591b..b37c37f20d 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx b/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx deleted file mode 100644 index 9d9cbed2e2..0000000000 --- a/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx +++ /dev/null @@ -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; -} diff --git a/frontend/editor/src/core/components/policies/usePoliciesEnabled.ts b/frontend/editor/src/core/components/policies/usePoliciesEnabled.ts new file mode 100644 index 0000000000..d8bb52e159 --- /dev/null +++ b/frontend/editor/src/core/components/policies/usePoliciesEnabled.ts @@ -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; +} diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx index 4333e263f1..41110ac034 100644 --- a/frontend/editor/src/core/components/tools/RightSidebar.tsx +++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx @@ -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() {

- {policiesEnabled && ( - - )}
{collapsedRailItems.map(({ id, tool }) => ( - {policyDetailActive ? ( -
- -
- ) : ( - <> - {!showPolicies && - (activeTool ? ( - - } - title={activeTool.name} - onClose={handleHeaderBack} - closeLabel={ + <> + {activeTool ? ( + + } + title={activeTool.name} + onClose={handleHeaderBack} + closeLabel={ + inToolView + ? t("toolPanel.backToAllTools", "Back to all tools") + : t("toolPanel.goBack", "Go back") + } + /> + ) : ( +
+ {showHeaderSearch ? ( +
+ +
+ ) : null} + {showCloseButton ? ( + + className="tool-panel__expand-btn" + > + + ) : ( -
- {showHeaderSearch ? ( -
- -
- ) : null} - {showCloseButton ? ( - - - - ) : ( - - - - )} -
- ))} + + + + )} +
+ )} - {showPolicies && ( - - - - } - /> - )} - - {showInlineSearch && ( -
- -
- )} - - - - )} + +
)} diff --git a/frontend/editor/src/core/tests/stubbed/policy-admin-gate.spec.ts b/frontend/editor/src/core/tests/stubbed/policy-admin-gate.spec.ts deleted file mode 100644 index 7e60a8c189..0000000000 --- a/frontend/editor/src/core/tests/stubbed/policy-admin-gate.spec.ts +++ /dev/null @@ -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); - }); -}); diff --git a/frontend/editor/src/desktop/components/policies/PoliciesSidebar.tsx b/frontend/editor/src/desktop/components/policies/PoliciesSidebar.tsx deleted file mode 100644 index c757b0d260..0000000000 --- a/frontend/editor/src/desktop/components/policies/PoliciesSidebar.tsx +++ /dev/null @@ -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(); -} diff --git a/frontend/editor/src/desktop/components/policies/usePoliciesEnabled.ts b/frontend/editor/src/desktop/components/policies/usePoliciesEnabled.ts new file mode 100644 index 0000000000..efc8f33fa7 --- /dev/null +++ b/frontend/editor/src/desktop/components/policies/usePoliciesEnabled.ts @@ -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(); +} diff --git a/frontend/editor/src/desktop/constants/featureFlags.ts b/frontend/editor/src/desktop/constants/featureFlags.ts index 957860b7cf..d003b3e642 100644 --- a/frontend/editor/src/desktop/constants/featureFlags.ts +++ b/frontend/editor/src/desktop/constants/featureFlags.ts @@ -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"; diff --git a/frontend/editor/src/portal-saas/components/pipelines/outputModes.test.ts b/frontend/editor/src/portal-saas/components/pipelines/outputModes.test.ts new file mode 100644 index 0000000000..093be2d9da --- /dev/null +++ b/frontend/editor/src/portal-saas/components/pipelines/outputModes.test.ts @@ -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"]); + }); +}); diff --git a/frontend/editor/src/portal-saas/components/pipelines/outputModes.ts b/frontend/editor/src/portal-saas/components/pipelines/outputModes.ts new file mode 100644 index 0000000000..c55ce3f487 --- /dev/null +++ b/frontend/editor/src/portal-saas/components/pipelines/outputModes.ts @@ -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"]; +} diff --git a/frontend/editor/src/portal-saas/components/sources/creatableSourceTypes.test.ts b/frontend/editor/src/portal-saas/components/sources/creatableSourceTypes.test.ts new file mode 100644 index 0000000000..1eec2ac959 --- /dev/null +++ b/frontend/editor/src/portal-saas/components/sources/creatableSourceTypes.test.ts @@ -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"); + }); +}); diff --git a/frontend/editor/src/portal-saas/components/sources/creatableSourceTypes.ts b/frontend/editor/src/portal-saas/components/sources/creatableSourceTypes.ts new file mode 100644 index 0000000000..9fa73be0ff --- /dev/null +++ b/frontend/editor/src/portal-saas/components/sources/creatableSourceTypes.ts @@ -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"); +} diff --git a/frontend/editor/src/portal/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts index 2fd5b2916e..e75711b912 100644 --- a/frontend/editor/src/portal/api/pipelines.ts +++ b/frontend/editor/src/portal/api/pipelines.ts @@ -29,6 +29,9 @@ export interface OutputSpec { options: Record; } +/** 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 { } /** - * 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 { - return apiClient.local.json( +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 { + return apiClient.local.json( `/api/v1/policies/${encodeURIComponent(id)}/trigger`, { method: "POST" }, ); diff --git a/frontend/editor/src/portal/api/procurement.ts b/frontend/editor/src/portal/api/procurement.ts index 26d73e5814..4d8a28e057 100644 --- a/frontend/editor/src/portal/api/procurement.ts +++ b/frontend/editor/src/portal/api/procurement.ts @@ -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 { @@ -343,10 +381,17 @@ export function fetchLicenseFile(): Promise { return apiClient.saas.text("/api/v1/procurement/license/file"); } -export function startTrial(): Promise { +/** + * 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 { return apiClient.saas.json( "/api/v1/procurement/trial/start", - { method: "POST" }, + { method: "POST", body: { deployment, users: seats } }, ); } diff --git a/frontend/editor/src/portal/components/HomeHero.tsx b/frontend/editor/src/portal/components/HomeHero.tsx index 643703f447..4c8c460405 100644 --- a/frontend/editor/src/portal/components/HomeHero.tsx +++ b/frontend/editor/src/portal/components/HomeHero.tsx @@ -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 ? ( ) : ( - + ); return ( diff --git a/frontend/editor/src/portal/components/SetupChecklist.tsx b/frontend/editor/src/portal/components/SetupChecklist.tsx index bc41c30dd6..b2f2df3b42 100644 --- a/frontend/editor/src/portal/components/SetupChecklist.tsx +++ b/frontend/editor/src/portal/components/SetupChecklist.tsx @@ -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 }) {
); } diff --git a/frontend/editor/src/portal/components/pipelines/outputModes.ts b/frontend/editor/src/portal/components/pipelines/outputModes.ts new file mode 100644 index 0000000000..a8e698c676 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/outputModes.ts @@ -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"]; +} diff --git a/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx b/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx index 5035ec49b1..9421de28bb 100644 --- a/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx @@ -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 = { args: { canSchedule: true, onExpand: () => {}, - onKeyDocs: () => {}, + onLicense: () => {}, onInvite: () => {}, onSchedule: () => {}, onManageTrial: () => {}, diff --git a/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx b/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx index 56e9de6d70..e2a3e19687 100644 --- a/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx +++ b/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx @@ -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({ })} )} - {stage !== "active" && ( + {snapshot.licenseKey && ( )} {stage !== "active" && ( @@ -120,7 +121,7 @@ export function DealStatusHero({
- +
{inTrial && ( diff --git a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx index 640fcdb2e3..336b6af5cb 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx @@ -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({ ))} -

3. End-User License Agreement

+

3. Term, renewal and annual fee adjustment

+

+ 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{" "} + {renewal} per year; the committed term above is + billed at the rate in the Order Form and is not affected. +

+ +

4. End-User License Agreement

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.

-

4. Data Processing Agreement

+

5. Data Processing Agreement

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.

-

5. Acceptance

+

6. Acceptance

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")} + + ); diff --git a/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx b/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx index 902bb14c0d..d47ffa0b48 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx @@ -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")} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx index 048bc2d606..44bbea1264 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx @@ -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 = { - 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 ( - {STAGE_DOCS.map((g) => ( -

-
{t(g.groupKey)}
-
    - {g.docs.map((d) => ( -
  • -
    - {t(d.nameKey)} - - {t(d.subKey)} - {d.fee - ? t("portal.procurement.keyDocs.oneTimeFee", { - amount: `$${d.fee.toLocaleString()}`, - }) - : ""} - -
    - - {t(STATUS_LABEL[d.status])} - -
  • - ))} -
-
- ))} + + {offlineAvailable && trial && ( +

+ {t("portal.procurement.license.trialFileHint")} +

+ )} ); } @@ -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("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 ( + onConfirm(deployment, Math.max(0, Number(seats) || 0))} + > + {t("portal.procurement.setup.start")} + + } + > + + + +

+ {t("portal.procurement.setup.seatsHint")} +

+
+ ); +} + // ── Trial management ───────────────────────────────────────────────────────── export function TrialManageModal({ open, diff --git a/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx b/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx index 94ee979b86..d1527c32bb 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx @@ -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 && ( <>
- +
{(editing || (isDraft && (stage === "trial" || stage === "quote"))) && ( )} - {!editing && isIssued && stage === "quote" && latest && ( - setEditing(true)} - /> - )} - - {!editing && stage === "security" && latest && ( - - )} + {/* 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 && ( + setEditing(true)} + /> + )} {!editing && stage === "procurement" && latest && ( )} {!editing && stage === "active" && } - - {data?.licenseKey && ( - - )} - -
- -
)} - setExtra(null)} + busy={busy} + onConfirm={onConfirmSetup} /> + {data?.licenseKey && ( + setExtra(null)} + licenseKey={data.licenseKey} + offlineAvailable={data.deployment === "airgap"} + downloadingLicense={downloadingLicense} + onDownloadOffline={onDownloadOfflineLicense} + trial={data.stage !== "procurement" && data.stage !== "active"} + /> + )} setExtra(null)} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx index bd5c0e3fc5..35460a7ba5 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx @@ -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 ( - - - {t("portal.procurement.milestone.eyebrow", { - number: quote.quoteNumber, - })} - -

- {t("portal.procurement.milestone.title")} -

- {quote.config.businessName && ( -

- {t("portal.procurement.milestone.preparedFor", { - company: quote.config.businessName, - })} -

- )} -

- {t("portal.procurement.milestone.description")} -

-
    - {quote.lineItems.map((li) => ( -
  • - {li.label} - - {li.kind === "INCLUDED" - ? t("portal.procurement.builder.included") - : money(li.amountMinor, quote.currency)} - -
  • - ))} -
-
- - {money(quote.annualNetMinor, quote.currency)} - {t("portal.procurement.milestone.perYear")} - - - {t("portal.procurement.milestone.tcv", { - value: money(quote.tcvMinor, quote.currency), - })} - -
-
- - - -
-
- ); -} - -/** 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({ )} )} -
- -
); } diff --git a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx index 1ba6016a4a..b392247563 100644 --- a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx +++ b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx @@ -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 = { - 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 & service → details. A client-side preview @@ -30,10 +37,13 @@ const SLA_UPLIFT: Record = { */ 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( 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")} > + +
+ {POSTURES.map((p) => ( + set("intensity", p.intensity)} + /> + ))} +
+
+ + +
+ {SIZE_TIERS.map((s) => ( + set("sizeMult", s.mult)} + /> + ))} +
+
+
{[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)} /> - set("offlineLicense", !cfg.offlineLicense)} - />
@@ -241,31 +286,97 @@ export function QuoteBuilder({ title={t("portal.procurement.builder.s3Title")} sub={t("portal.procurement.builder.s3Sub")} > - +
+ + set("businessName", e.target.value)} + /> + + + set("contactName", e.target.value)} + /> + +
+ + set("contactEmail", e.target.value)} + /> + + set("businessName", e.target.value)} + value={cfg.addressLine1 ?? ""} + onChange={(e) => set("addressLine1", e.target.value)} + /> + + + set("addressLine2", e.target.value)} />
- - + + set("city", e.target.value)} + /> + + + set("region", e.target.value)} + /> + + + set("postalCode", e.target.value)} + /> + +
+
+ + set("poNumber", e.target.value)} + /> + + + set("taxId", e.target.value)} + />