Condense comments to single lines

This commit is contained in:
Anthony Stirling
2026-07-13 20:28:07 +01:00
parent 8cb03dba9e
commit cb40d7778f
22 changed files with 47 additions and 244 deletions
@@ -255,11 +255,7 @@ public class ApplicationProperties {
*/
private boolean allowPrivateS3Endpoints = false;
/**
* Largest inbound document (bytes) the webhook source receiver will accept in one delivery.
* A larger body is rejected with 413 before anything is written to the spool, so an
* unauthenticated caller cannot fill the disk. Default 100 MB.
*/
/** Max inbound webhook body (bytes); over it is rejected 413. Default 100 MB. */
private long webhookMaxBytes = 104857600L;
}
@@ -198,8 +198,7 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/readiness")
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
// Policy webhook source receiver - authenticated per-request by its HMAC signature,
// not a login session, so it must bypass the session auth wall.
// Webhook receiver: authenticated per-request by HMAC signature, not a session.
|| trimmedUri.startsWith("/api/v1/webhooks/")
|| trimmedUri.startsWith("/v1/api-docs")
// Workflow participant endpoints - access controlled by share tokens, not login
@@ -23,13 +23,7 @@ public interface InputSource {
/** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */
default void validate(InputSpec spec) {}
/**
* Normalise a source's options just before it is persisted, so a source type can populate
* server-owned config the client neither supplies nor controls. {@code isCreate} is true only
* for a brand-new source (blank id). Runs before {@link #validate}. The default returns the
* options unchanged; the webhook source overrides it to mint its routing id and signing secret
* on create. Must not mutate the argument.
*/
/** Normalise a source's options before persistence (default no-op); must not mutate the arg. */
default Map<String, Object> prepareOptionsForSave(
Map<String, Object> options, boolean isCreate) {
return options;
@@ -26,25 +26,7 @@ import stirling.software.proprietary.policy.webhook.WebhookConfig;
import stirling.software.proprietary.policy.webhook.WebhookIds;
import stirling.software.proprietary.policy.webhook.WebhookSpool;
/**
* A push source: documents arrive by signed HTTP POST to {@code /api/v1/webhooks/{webhookId}} and
* are staged for the referencing policies. Where they are staged depends on the source's config
* (see {@link WebhookConfig}):
*
* <ul>
* <li>no {@code connectionId}: a node-local {@link WebhookSpool} directory, read exactly as
* {@link FolderInputSource} reads a watched directory (self-hosted);
* <li>a {@code connectionId}: the referenced S3 {@code IntegrationConfig} connection, under a
* reserved per-webhook prefix - read, claimed, and consumed by delegating to {@link
* S3InputSource}, so the durable, multi-node model hosted deployments need is reused, not
* reinvented.
* </ul>
*
* <p>Either way the {@link ResolveContext} ledger does the claiming, "mode" is "consume" (default:
* a staged document is removed once every policy that claimed it has settled successfully) or
* "snapshot" (stateless), and "webhookId"/"signingSecret" are minted server-side on create. Empty
* staging reads as "verifiably no files".
*/
/** Push source: deliveries staged to an S3 connection or the local spool, read via the ledger. */
@Slf4j
@Service
@RequiredArgsConstructor
@@ -55,9 +37,7 @@ public class WebhookInputSource implements InputSource {
private final WebhookSpool spool;
private final FileReadinessChecker readinessChecker;
// Deliveries staged to an S3 connection are read/claimed/consumed exactly like an S3 source, so
// that whole path is reused rather than reimplemented; a connection-less webhook uses the
// spool.
// Connection-backed staging delegates to the S3 source; else the local spool.
private final S3InputSource s3InputSource;
@Override
@@ -74,16 +54,12 @@ public class WebhookInputSource implements InputSource {
public void validate(InputSpec spec) {
WebhookConfig config = WebhookConfig.from(spec.options());
if (config.usesConnection()) {
// Resolves the connection with the caller present, so save fails if their team can't
// use it (the ownership check the delivery path then trusts).
// Resolve with the caller present so save fails if they can't use the connection.
s3InputSource.validate(stagingSpec(config));
}
}
/**
* The S3 view of a webhook's durable staging: its connection plus the reserved per-webhook
* prefix, as an {@code s3} input spec {@link S3InputSource} can resolve directly.
*/
/** The webhook's durable staging as an {@code s3} input spec for {@link S3InputSource}. */
private static InputSpec stagingSpec(WebhookConfig config) {
return new InputSpec(
"s3",
@@ -96,11 +72,7 @@ public class WebhookInputSource implements InputSource {
config.mode()));
}
/**
* Mint the routing id and signing secret on create, so the delivery URL and its HMAC key are
* always server-generated. An edit (or any options that already carry a webhookId) is left
* untouched, so the URL a sender is already configured against never changes underneath them.
*/
/** Mint the routing id + signing secret on create; an existing webhook is left untouched. */
@Override
public Map<String, Object> prepareOptionsForSave(
Map<String, Object> options, boolean isCreate) {
@@ -125,16 +97,12 @@ public class WebhookInputSource implements InputSource {
public List<ResolvedInput> resolve(InputSpec spec, ResolveContext ctx) throws IOException {
WebhookConfig config = WebhookConfig.from(spec.options());
if (config.usesConnection()) {
// Durable staging: the delivery objects live in the connection's bucket and are read,
// claimed, and consumed by the S3 source (no principal here - the save-time check the
// resolver trusts already ran).
// Durable staging read by the S3 source (no principal; the save-time check is trusted).
return s3InputSource.resolve(stagingSpec(config), ctx);
}
Path dir = spool.dirFor(config.webhookId());
if (!Files.isDirectory(dir)) {
// No deliveries yet (or none since the last consume): a verifiably empty source, so the
// sweep may prune ledger rows for files that are gone. Unlike the folder source a
// missing directory is normal here, not an unmounted-drive error.
// No deliveries yet: verifiably empty (a missing dir is normal here, not an error).
ctx.reportPresent(List.of());
return List.of();
}
@@ -182,13 +150,7 @@ public class WebhookInputSource implements InputSource {
return work;
}
/**
* Settle at the claimed version, then remove the spooled file only when it is still that
* version and every policy that claimed it has settled DONE - the same consensus delete the
* folder and S3 sources use, so a shared webhook feeding several policies keeps a delivery
* until all are done and one failure parks it for everyone. A failed run settles ERROR and
* never deletes.
*/
/** Settle, then delete when unchanged and every claimant settled DONE (consensus). */
private static void completeConsumed(
ResolveContext ctx, String identity, Path file, String claimGate, boolean success) {
ctx.settle(identity, claimGate, null, success);
@@ -231,11 +231,7 @@ public class SourceController {
.validate(spec);
}
/**
* Let the matching source type populate server-owned config just before persistence (a webhook
* mints its routing id and signing secret on create). An unknown type is left untouched; {@link
* #validateConfig} then rejects it with a clear message.
*/
/** Let the source type populate server-owned config before persistence. */
private Source withPreparedOptions(Source source, boolean isCreate) {
InputSpec spec = source.toInputSpec();
InputSource input = inputSourceFor(spec).orElse(null);
@@ -243,16 +239,11 @@ public class SourceController {
return source;
}
Map<String, Object> prepared = input.prepareOptionsForSave(source.options(), isCreate);
// A source type must return the (possibly augmented) options; if it returns null, keep the
// originals rather than let the Source record normalise null to an empty map and wipe
// config.
// Treat a null return as "unchanged" (else the record wipes config to empty).
return prepared == null ? source : withOptions(source, prepared);
}
/**
* Reveal server-minted secrets (a webhook's signing secret) once, on the create response only,
* so the operator can copy them; every other read - including an edit - is masked.
*/
/** Reveal a webhook's minted secret once on the create response; other reads mask. */
private static Source revealOnCreate(Source saved, boolean isCreate) {
if (isCreate && WEBHOOK_TYPE.equals(saved.type())) {
return saved;
@@ -2,13 +2,7 @@ package stirling.software.proprietary.policy.source;
import java.util.List;
/**
* One row in the Sources overview: a persisted input connection shown exactly once, with how many
* policies reference it (and which) and how many documents it has fed into runs ({@code docsTotal}
* lifetime plus the trailing 24-hour and 30-day windows). {@code webhookPath} is set only for a
* webhook source - the server-relative delivery path senders POST to - and is null for every other
* type; the client turns it into an absolute URL.
*/
/** One Sources-overview row; {@code webhookPath} is a webhook's delivery path, else null. */
public record SourceView(
String id,
String name,
@@ -20,16 +20,7 @@ import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.policy.webhook.WebhookConfig;
/**
* Fires policies when a document is delivered to one of their webhook sources. Delivery is push, so
* the receiver calls {@link #fireForWebhook} the moment it spools a file and the referencing
* policies run immediately. As with folder-watch, the instant fire is a latency optimisation, not
* the source of truth: a periodic reconcile ({@code watchReconcileSeconds}) re-runs every webhook
* policy to cover deliveries that landed while the process was down and any fire that was missed.
* Redundant runs are harmless because the input source does the claiming.
*
* <p>The spool is on local disk, so - like folder-watch - this assumes a single node.
*/
/** Fires webhook policies on delivery plus a periodic reconcile safety net. */
@Slf4j
@Service
@RequiredArgsConstructor
@@ -37,8 +28,7 @@ import stirling.software.proprietary.policy.webhook.WebhookConfig;
public class WebhookTrigger implements PolicyTrigger {
static final String TYPE = "webhook";
// The webhook source type; equal to the trigger type but a distinct concept (source vs
// trigger).
// The webhook source type (equals the trigger type but a distinct concept).
private static final String WEBHOOK_SOURCE_TYPE = "webhook";
private final PolicyStore policyStore;
@@ -98,12 +88,7 @@ public class WebhookTrigger implements PolicyTrigger {
}
}
/**
* Run every webhook policy that references the delivered-to source. Called by the receiver
* right after it spools a document, so processing starts without waiting for the next
* reconcile. A LIGHT sweep: the periodic reconcile does the full-listing pass. Best-effort - a
* run failure is logged and the others still fire.
*/
/** Run every webhook policy referencing the delivered-to source (LIGHT, best-effort). */
public void fireForWebhook(String webhookId) {
for (Policy policy : policyStore.findByTriggerType(TYPE)) {
if (!referencesWebhook(policy, webhookId)) {
@@ -2,20 +2,7 @@ package stirling.software.proprietary.policy.webhook;
import java.util.Map;
/**
* Config for a webhook input source, parsed from a spec's options map. A webhook is a push source:
* external systems POST documents to a signed URL keyed by {@code webhookId}, and each delivery is
* verified against {@code signingSecret} before it is staged for the referencing policies. Both
* {@code webhookId} (the public URL token) and {@code signingSecret} (the HMAC key) are generated
* server-side on create - a client never supplies them.
*
* <p>Where deliveries are staged depends on {@code connectionId}: absent, they go to a node-local
* spool directory (self-hosted); present, they go to the S3 {@code IntegrationConfig} connection it
* references, under a reserved per-webhook prefix, so the staging is durable and reachable from any
* node (the model hosted/SaaS needs). Either way {@code mode} is "consume" (default: a staged
* document is removed once every policy that claimed it has settled successfully) or "snapshot"
* (stateless, every run re-reads whatever is currently staged).
*/
/** Webhook source config: server-minted ids, staging via an S3 connection or the local spool. */
public record WebhookConfig(
String webhookId, String signingSecret, Long connectionId, boolean snapshot) {
@@ -55,11 +42,7 @@ public record WebhookConfig(
return connectionId != null;
}
/**
* The reserved key prefix this webhook's deliveries are staged under inside its connection's
* bucket. Server-derived from the (unguessable) id, so two webhooks sharing a connection never
* collide and an S3 source pointed at the same bucket won't see another webhook's inbox.
*/
/** Reserved per-webhook staging prefix inside the connection's bucket. */
public String stagingPrefix() {
return STAGING_ROOT + "/" + webhookId;
}
@@ -4,17 +4,10 @@ import java.security.SecureRandom;
import java.util.Base64;
import java.util.regex.Pattern;
/**
* Generates and validates the two server-side tokens a webhook source carries: the public {@code
* webhookId} that routes a delivery URL to a source, and the {@code signingSecret} that
* authenticates each delivery. Both are URL-safe base64 of {@link SecureRandom} bytes, so both are
* unguessable; the id is the routing capability and the secret is the HMAC key. The id's character
* set is constrained so it can be used verbatim as a single path segment and as a spool directory
* name without traversal risk.
*/
/** Generates and validates a webhook's routing id and signing secret. */
public final class WebhookIds {
/** URL-safe base64 without padding: exactly the characters a single path segment allows. */
/** URL-safe base64 without padding: the characters a single path segment allows. */
private static final Pattern VALID_ID = Pattern.compile("^[A-Za-z0-9_-]{16,128}$");
private static final SecureRandom RANDOM = new SecureRandom();
@@ -22,9 +15,7 @@ public final class WebhookIds {
private WebhookIds() {}
/**
* A fresh routing token (~24 chars). Not secret, but unguessable so it cannot be enumerated.
*/
/** A fresh routing token (~24 chars), unguessable so it cannot be enumerated. */
public static String newWebhookId() {
return randomToken(18);
}
@@ -34,9 +25,7 @@ public final class WebhookIds {
return randomToken(32);
}
/**
* Whether {@code id} is a well-formed webhook id, safe as a path segment and directory name.
*/
/** Whether {@code id} is well-formed and safe as a path segment / directory name. */
public static boolean isValidId(String id) {
return id != null && VALID_ID.matcher(id).matches();
}
@@ -38,22 +38,7 @@ import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
/**
* Public receiver for webhook input sources. External systems POST a document to {@code
* /api/v1/webhooks/{webhookId}}; the request is authenticated not by a login session but by an
* HMAC-SHA256 signature of the exact body under the source's signing secret, so this endpoint sits
* outside the session auth wall (see {@code RequestUriUtils.isPublicAuthEndpoint}). A verified
* delivery is spooled and the referencing policies are fired immediately.
*
* <p>The request body is the raw document bytes, sent with a binary content type - {@code
* application/pdf} or {@code application/octet-stream} (e.g. {@code curl --data-binary @file.pdf -H
* 'Content-Type: application/pdf'}). A form content type ({@code
* application/x-www-form-urlencoded}) is rejected upstream because the container tries to parse the
* body as form fields. The signature header is {@code sha256=<hex>}. Delivery is rejected before
* anything is written if the id is unknown (404), the signature is missing or wrong (401), the
* source is paused (403), or the body is empty (400) or larger than {@code
* policies.webhookMaxBytes} (413).
*/
/** Public receiver: HMAC-verifies a signed delivery, stages it, and fires the policies. */
@Slf4j
@RestController
@RequestMapping("/api/v1/webhooks")
@@ -151,11 +136,7 @@ public class WebhookReceiverController {
}
}
/**
* Stage a delivery to the webhook's S3 connection under its reserved prefix. Resolved with no
* principal - the referencing source's right to use the connection was checked when it was
* saved - so a public delivery never needs the caller to hold connection rights.
*/
/** Stage a delivery to the S3 connection (save-time access check trusted). */
private String stageToConnection(WebhookConfig config, String filename, byte[] body) {
S3Config s3 =
connectionResolver.resolve(
@@ -191,11 +172,7 @@ public class WebhookReceiverController {
return prefix + "/";
}
/**
* Read the body into memory, capped at {@code policies.webhookMaxBytes}. Reading one byte past
* the limit is enough to reject an over-sized (or unbounded, chunked) delivery with 413 before
* it can fill memory or the disk.
*/
/** Read the body into memory, capped at {@code policies.webhookMaxBytes}. */
private byte[] readBoundedBody(HttpServletRequest request) {
long maxBytes = applicationProperties.getPolicies().getWebhookMaxBytes();
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
@@ -9,13 +9,7 @@ import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* HMAC-SHA256 signing scheme for webhook deliveries. The sender signs the exact request body bytes
* with the source's {@code signingSecret} and presents the result as {@code sha256=<hex>} in the
* signature header; the receiver recomputes it and compares in constant time. Signing the raw body
* (rather than form fields) keeps the contract unambiguous - what is signed is exactly what is
* delivered - and matches how established webhook providers (Stripe, GitHub) sign payloads.
*/
/** HMAC-SHA256 signing of a webhook delivery's raw body (header sha256=<hex>). */
public final class WebhookSignatures {
private static final String ALGORITHM = "HmacSHA256";
@@ -23,18 +17,12 @@ public final class WebhookSignatures {
private WebhookSignatures() {}
/**
* The header value a sender should present for {@code body}: {@code sha256=<lowercase hex>}.
*/
/** The header a sender presents for {@code body}: {@code sha256=<lowercase hex>}. */
public static String sign(String signingSecret, byte[] body) {
return PREFIX + HexFormat.of().formatHex(hmac(signingSecret, body));
}
/**
* Whether {@code presented} (a {@code sha256=<hex>} header value, or a bare hex string) is a
* valid signature of {@code body} under {@code signingSecret}. Constant-time in the compared
* bytes; a malformed or missing header is simply false, never an exception.
*/
/** Whether the presented signature is valid for {@code body}; false on bad input. */
public static boolean verify(String signingSecret, byte[] body, String presented) {
if (signingSecret == null || presented == null || body == null) {
return false;
@@ -11,16 +11,7 @@ import org.springframework.stereotype.Component;
import stirling.software.common.configuration.InstallationPathConfig;
/**
* Server-owned staging area for webhook deliveries. Each webhook source gets its own directory,
* named by its (unguessable, path-segment-safe) {@code webhookId}, under a fixed root inside
* Stirling's install path - never a user-supplied location, so this is not subject to the folder
* source's allow-listing. The receiver writes each accepted document here atomically; {@link
* stirling.software.proprietary.policy.input.WebhookInputSource} then reads it exactly as the
* folder source reads a watched directory, reusing the whole claim/consume ledger. A delivered file
* keeps its original name for downstream output naming, prefixed with a unique token so two
* deliveries of the same name never collide.
*/
/** Server-owned per-webhook staging directory under the install path. */
@Component
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class WebhookSpool {
@@ -54,12 +45,7 @@ public class WebhookSpool {
return dir;
}
/**
* Write one delivered document into the webhook's spool, atomically so a partial write is never
* picked up: staged under a hidden {@code .part} name and moved into place. Returns the final
* path. The original filename (sanitised to a bare, safe basename) is preserved after a unique
* prefix.
*/
/** Write a delivery into the spool atomically (staged .part, then moved into place). */
public Path store(String webhookId, String filename, byte[] content) throws IOException {
Path dir = dirFor(webhookId);
Files.createDirectories(dir);
@@ -90,19 +76,12 @@ public class WebhookSpool {
return spoolFileName;
}
/**
* The object-key suffix for an S3-staged delivery: a unique segment then the sanitised name
* ({@code <unique>/<name>}). A subfolder rather than a name prefix so the object's basename -
* and thus the resolved input's filename - stays the clean original name, matching the local
* spool.
*/
/** S3 object-key suffix {@code <unique>/<name>}; the subfolder keeps the basename clean. */
public static String objectKeySuffix(String filename) {
return UUID.randomUUID().toString().replace("-", "") + "/" + sanitize(filename);
}
/**
* The sanitised display name of a staged delivery (the basename of {@link #objectKeySuffix}).
*/
/** The staged delivery's display name (basename of {@link #objectKeySuffix}). */
public static String objectDisplayName(String filename) {
return sanitize(filename);
}
@@ -20,9 +20,7 @@ public final class SecretMasker {
private static final Pattern SENSITIVE =
RegexPatternUtils.getInstance()
.getPattern(
// secret[_-]?access[_-]?key and signing[_-]?secret precede plain secret
// so camelCase keys like secretAccessKey and signingSecret (whose inner
// "secret" has no word boundary before/after it) still match.
// specific keys precede plain "secret" so camelCase still matches.
"(?i)\\b(password|token|secret[_-]?access[_-]?key|signing[_-]?secret|secret|api[_-]?key|authorization|auth|jwt|cred|cert)\\b");
private SecretMasker() {}
@@ -34,11 +34,7 @@ import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.webhook.WebhookConfig;
import stirling.software.proprietary.policy.webhook.WebhookSpool;
/**
* Tests for {@link WebhookInputSource}: spooled deliveries are read through the ledger like folder
* files (consume removes them once processed, snapshot stays stateless), a not-yet-created spool is
* an empty source rather than an error, and options gain a routing id + signing secret on create.
*/
/** Tests for {@link WebhookInputSource}: ledger-backed read/consume, and id/secret minting. */
@ExtendWith(MockitoExtension.class)
class WebhookInputSourceTest {
@@ -193,8 +189,7 @@ class WebhookInputSourceTest {
source.validate(spec);
// Save-time validation defers to the S3 source, which resolves + ownership-checks the
// connection (the check the delivery path later trusts).
// Save-time validation defers to the S3 source, which ownership-checks the connection.
verify(s3InputSource).validate(any());
}
@@ -70,9 +70,7 @@ class SourceControllerTest {
policyGuard,
new InProcessSourceDocCounter());
triggerManager = mock(PolicyTriggerManager.class);
// A permissive input source so config validation passes and save can be exercised. Model a
// real source's prepareOptionsForSave as a pass-through (a bare mock would return an empty
// map for the Map-typed method and wipe the config).
// A permissive input source; stub prepareOptionsForSave as a pass-through.
InputSource folderInput = mock(InputSource.class);
when(folderInput.supports(any())).thenReturn(true);
when(folderInput.prepareOptionsForSave(any(), anyBoolean()))
@@ -27,10 +27,7 @@ import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Tests for {@link WebhookTrigger}'s dispatch: a delivery fires only the policies that reference
* the delivered-to webhook, and validation requires at least one webhook source.
*/
/** Tests for {@link WebhookTrigger}: fires only policies referencing the delivered-to webhook. */
@ExtendWith(MockitoExtension.class)
class WebhookTriggerTest {
@@ -31,11 +31,7 @@ import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.trigger.WebhookTrigger;
import stirling.software.proprietary.policy.webhook.WebhookReceiverController.WebhookDeliveryResponse;
/**
* Tests for the public webhook receiver: a signature-valid delivery is spooled and fires the
* trigger; an unknown id, wrong signature, paused source, empty body, or over-sized body is
* rejected before anything is stored.
*/
/** Tests for the public webhook receiver: valid delivery spools + fires; bad requests rejected. */
class WebhookReceiverControllerTest {
private static final String WEBHOOK_ID = "receivertestid12";
@@ -54,13 +54,7 @@ import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
import software.amazon.awssdk.services.s3.model.S3Object;
import tools.jackson.databind.ObjectMapper;
/**
* End-to-end test of a connection-backed webhook against a real S3 API (MinIO): a signed delivery
* is staged by the receiver into the S3 connection's bucket under the reserved per-webhook prefix,
* then read and consumed by the source (which delegates to {@link S3InputSource}). Proves the
* durable, multi-node staging model hosted deployments use, through the production S3 client
* factory.
*/
/** End-to-end connection-backed webhook against MinIO: stage, read via S3 delegation, consume. */
@Testcontainers(disabledWithoutDocker = true)
class WebhookS3ConnectionMinioTest {
@@ -112,8 +106,7 @@ class WebhookS3ConnectionMinioTest {
ApplicationProperties properties = new ApplicationProperties();
properties.getPolicies().setAllowPrivateS3Endpoints(true);
// A resolver over a stored S3 connection pointing at MinIO. No SecurityContext in the test,
// so the resolver's ownership check is skipped (the delivery path's trust-the-save model).
// A resolver over a MinIO S3 connection; no principal here, so ownership is skipped.
S3ConnectionResolver resolver = resolverFor(minioConnection());
S3ConnectionPool pool = new S3ConnectionPool(properties);
S3InputSource s3 = new S3InputSource(pool, resolver);
@@ -4,13 +4,7 @@ import {
type CreatableSourceType,
} from "@portal/components/sources/sourceTypes";
/**
* Hosted deployments don't rely on the server's local filesystem. Folder sources
* are dropped outright (denied by the backend's FolderAccessGuard). Webhook stays
* - it works in hosted by staging deliveries to a durable S3 connection - but its
* connection becomes required, since the node-local fallback can't be relied on
* across a multi-node fleet. Cloud sources such as S3 are unchanged.
*/
/** Hosted drops folder (no server FS) and requires webhook's S3 connection (node-local isn't durable). */
export function creatableSourceTypes(): CreatableSourceType[] {
return CREATABLE_SOURCE_TYPES.filter((type) => type.type !== "folder").map(
(type) =>
@@ -189,9 +189,7 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [
],
},
{
// The delivery URL + signing secret are minted server-side on create (revealed once). An S3
// connection makes staging durable + multi-node (required in hosted); left blank, deliveries
// stage to the server's local disk (self-hosted only).
// URL + secret minted server-side (revealed once); an S3 connection makes staging durable, else local disk.
type: WEBHOOK_SOURCE_TYPE,
labelKey: "portal.sources.types.webhook.label",
descriptionKey: "portal.sources.types.webhook.description",
@@ -234,8 +234,7 @@ export const sourcesHandlers = [
? store.find((s) => s.id === incoming.id)
: undefined;
const id = existing?.id ?? nextId();
// A new webhook has its routing id + signing secret minted server-side, then revealed once on
// this create response (the store keeps them; later reads mask the secret).
// A new webhook's id + secret are minted server-side and revealed once on this create response.
let options = incoming.options ?? {};
if (incoming.type === "webhook" && !existing && !options.webhookId) {
options = {
@@ -123,8 +123,7 @@ export function SourceBuilder() {
);
const canSave = name.trim() !== "" && requiredComplete && !submitting;
// An existing webhook's delivery URL, shown read-only in the edit view (the secret is not - it
// is revealed only once, at creation).
// An existing webhook's delivery URL, read-only in edit (the secret is revealed only at creation).
const editingWebhookId =
isEdit && sourceState.data?.type === WEBHOOK_SOURCE_TYPE
? String(sourceState.data.options?.webhookId ?? "")
@@ -149,8 +148,7 @@ export function SourceBuilder() {
options,
enabled,
});
// A new webhook returns its server-minted routing id + signing secret once; reveal them
// (with the delivery URL) before leaving so the operator can copy the secret.
// A new webhook returns its minted id + secret once; reveal them (with the URL) before leaving.
if (!isEdit && type.type === WEBHOOK_SOURCE_TYPE) {
const webhookId = String(saved.options?.webhookId ?? "");
const secret = String(saved.options?.signingSecret ?? "");