Merge branch 'main' into worktree-jpa-persistence-platform

This commit is contained in:
DongHyeonka
2026-08-14 14:06:21 +09:00
941 changed files with 60383 additions and 177 deletions
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
/**
* Security chain for the provider callback endpoints.
*
* <p>Callbacks authenticate with a provider signature, not with a user session, so they get their
* own chain: CSRF and session creation are off, and the ordinary user chain never sees them.
* Putting them on the user chain would either break every provider or force the user chain to be
* permissive.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(
prefix = "ca-skeleton.notification.platform.callbacks",
name = "enabled",
havingValue = "true")
public class CallbackMvcSecurityConfiguration {
/** Dedicated, ordered-first chain for the callback path. */
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public SecurityFilterChain notificationCallbackFilterChain(HttpSecurity http) throws Exception {
return http.securityMatcher("/internal/notification/callbacks/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(requests -> requests.anyRequest().permitAll())
.build();
}
}
@@ -0,0 +1,75 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.callback.CallbackRequest;
import jakarta.servlet.http.HttpServletRequest;
import java.time.Clock;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Builds the transport-neutral callback request.
*
* <p>Both the servlet and reactive endpoints use this, so signature verification sees exactly the
* same canonical bytes and URL regardless of which stack received the call.
*/
public final class CallbackRequestFactory {
private final ExternalRequestUrlResolver urlResolver;
private final Clock clock;
public CallbackRequestFactory(ExternalRequestUrlResolver urlResolver, Clock clock) {
this.urlResolver = Objects.requireNonNull(urlResolver, "urlResolver");
this.clock = Objects.requireNonNull(clock, "clock");
}
/** Build from a servlet request plus the already-read raw body. */
public CallbackRequest create(
String provider, String profile, HttpServletRequest request, byte[] body) {
Objects.requireNonNull(provider, "provider");
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(request, "request");
Objects.requireNonNull(body, "body");
Map<String, List<String>> headers = new LinkedHashMap<>();
for (String name : Collections.list(request.getHeaderNames())) {
headers.put(name, new ArrayList<>(Collections.list(request.getHeaders(name))));
}
return new CallbackRequest(
new ProviderId(provider),
new ProviderProfileId(profile),
urlResolver.resolve(request),
request.getMethod(),
Optional.ofNullable(request.getContentType()),
headers,
body,
clock.instant());
}
/** Build from an already-resolved external URL, used by the reactive endpoint. */
public CallbackRequest create(
String provider,
String profile,
String externalUrl,
String method,
Optional<String> contentType,
Map<String, List<String>> headers,
byte[] body) {
return new CallbackRequest(
new ProviderId(provider),
new ProviderProfileId(profile),
externalUrl,
method,
contentType,
headers,
body,
clock.instant());
}
}
@@ -0,0 +1,71 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/**
* Reconstructs the URL the provider actually called.
*
* <p>Several providers sign the request URL, so getting this wrong turns every valid webhook into a
* signature failure. Forwarded headers are only honoured when the immediate peer is a configured
* trusted proxy: trusting them unconditionally would let any caller choose the URL that gets
* verified, which defeats the signature entirely.
*/
public final class ExternalRequestUrlResolver {
private final Set<String> trustedProxies;
public ExternalRequestUrlResolver(Set<String> trustedProxies) {
this.trustedProxies = Set.copyOf(Objects.requireNonNull(trustedProxies, "trustedProxies"));
}
/** External URL of a request. */
public String resolve(HttpServletRequest request) {
Objects.requireNonNull(request, "request");
String scheme = request.getScheme();
String host = request.getServerName();
int port = request.getServerPort();
if (trustedProxies.contains(request.getRemoteAddr())) {
String forwarded = request.getHeader("Forwarded");
if (forwarded != null) {
for (String element : forwarded.split(";", -1)) {
String trimmed = element.trim().toLowerCase(Locale.ROOT);
if (trimmed.startsWith("proto=")) {
scheme = trimmed.substring("proto=".length());
} else if (trimmed.startsWith("host=")) {
host = element.trim().substring("host=".length());
port = -1;
}
}
} else {
String protoHeader = request.getHeader("X-Forwarded-Proto");
String hostHeader = request.getHeader("X-Forwarded-Host");
if (protoHeader != null) {
scheme = protoHeader;
}
if (hostHeader != null) {
host = hostHeader;
port = -1;
}
}
}
StringBuilder url = new StringBuilder(scheme).append("://").append(host);
boolean defaultPort =
port < 0
|| ("https".equalsIgnoreCase(scheme) && port == 443)
|| ("http".equalsIgnoreCase(scheme) && port == 80);
if (!defaultPort) {
url.append(':').append(port);
}
url.append(request.getRequestURI());
String query = request.getQueryString();
if (query != null && !query.isBlank()) {
url.append('?').append(query);
}
return url.toString();
}
}
@@ -0,0 +1,75 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException;
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Objects;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Servlet callback endpoint.
*
* <p>The body arrives as raw bytes, never as a parsed form. Providers sign the exact octets, and
* letting the container parse and re-encode them is the most common cause of a valid webhook
* failing verification.
*
* <p>The response is a bare {@code 204}: no body, no diagnostics. A provider only needs to know the
* event is recorded, and an error body would be a channel for leaking what the platform knows.
*
* <p>Registered only in a servlet application and only when callbacks are enabled. An annotated
* controller is also honoured by WebFlux, so without the servlet condition a reactive deployment
* would map both this and the functional router onto the same path — and a provider signature would
* then be verified twice against two different canonical URLs.
*/
@RestController
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnProperty(
prefix = "ca-skeleton.notification.platform.callbacks",
name = "enabled",
havingValue = "true")
@RequestMapping("/internal/notification/callbacks")
public final class NotificationCallbackMvcController {
/** Hard body ceiling applied before any provider adapter is consulted. */
public static final int MAX_BODY_BYTES = 65_536;
private final ProviderCallbackIngestionService ingestion;
private final CallbackRequestFactory requestFactory;
public NotificationCallbackMvcController(
ProviderCallbackIngestionService ingestion, CallbackRequestFactory requestFactory) {
this.ingestion = Objects.requireNonNull(ingestion, "ingestion");
this.requestFactory = Objects.requireNonNull(requestFactory, "requestFactory");
}
/** Receive one provider callback. */
@PostMapping(path = "/{provider}/{profile}")
public ResponseEntity<Void> callback(
@PathVariable String provider,
@PathVariable String profile,
HttpServletRequest request,
@RequestBody byte[] body) {
if (body.length > MAX_BODY_BYTES) {
return ResponseEntity.status(HttpStatus.CONTENT_TOO_LARGE).build();
}
// A duplicate answers 204 exactly like a first delivery. The provider did its job either way,
// and any other status would make it retry an event that is already recorded.
ingestion.ingest(requestFactory.create(provider, profile, request, body));
return ResponseEntity.noContent().build();
}
/** A rejected callback never reveals why beyond the status code. */
@ExceptionHandler(CallbackValidationException.class)
public ResponseEntity<Void> onValidationFailure(CallbackValidationException failure) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive;
import java.util.Objects;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.web.reactive.function.server.ServerRequest;
import reactor.core.publisher.Mono;
/**
* Reads the raw body with a hard ceiling and no buffer leaks.
*
* <p>Every {@link DataBuffer} is released on success, on error and on cancellation. A reactive
* endpoint that forgets the cancellation path leaks native memory exactly when it is under the load
* that caused the cancellation.
*/
public final class BoundedCallbackBodyReader {
private final int maxBytes;
public BoundedCallbackBodyReader(int maxBytes) {
if (maxBytes < 1) {
throw new IllegalArgumentException("maxBytes");
}
this.maxBytes = maxBytes;
}
/** Read at most the configured number of bytes. */
public Mono<byte[]> read(ServerRequest request) {
Objects.requireNonNull(request, "request");
return DataBufferUtils.join(request.bodyToFlux(DataBuffer.class), maxBytes)
.map(
buffer -> {
try {
byte[] bytes = new byte[buffer.readableByteCount()];
buffer.read(bytes);
return bytes;
} finally {
DataBufferUtils.release(buffer);
}
})
.defaultIfEmpty(new byte[0]);
}
/** Configured ceiling. */
public int maxBytes() {
return maxBytes;
}
}
@@ -0,0 +1,58 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive;
import dev.caskeleton.adapter.inbound.web.notification.platform.callback.CallbackRequestFactory;
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
/**
* Registers the reactive callback transport, and only it.
*
* <p>This configuration is {@code REACTIVE}-only and the servlet controller carries the matching
* {@code SERVLET} condition, so exactly one of the two is ever registered — by construction rather
* than by convention. Both on the same path would mean a provider signature is verified twice
* against two different canonical URLs, a failure that shows up only in production and only for
* signed providers, and reads like a credential problem.
*
* <p>The body ceiling is read as a property rather than through the platform settings type: that
* type belongs to the outbound notification adapter, which this inbound adapter must not depend on.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
@ConditionalOnProperty(
prefix = "ca-skeleton.notification.platform.callbacks",
name = "enabled",
havingValue = "true")
public class CallbackWebFluxConfiguration {
/** Bounded body reader; the ceiling applies before any provider adapter is consulted. */
@Bean
@ConditionalOnMissingBean
public BoundedCallbackBodyReader notificationCallbackBodyReader(
@Value("${ca-skeleton.notification.platform.callbacks.max-body-bytes:65536}") int maxBytes) {
return new BoundedCallbackBodyReader(maxBytes);
}
/** Reactive handler. */
@Bean
@ConditionalOnMissingBean
public NotificationCallbackWebFluxHandler notificationCallbackWebFluxHandler(
ProviderCallbackIngestionService ingestion,
CallbackRequestFactory requestFactory,
BoundedCallbackBodyReader bodyReader) {
return new NotificationCallbackWebFluxHandler(ingestion, requestFactory, bodyReader);
}
/** Functional route for the callback path. */
@Bean
public RouterFunction<ServerResponse> notificationCallbackRoutes(
NotificationCallbackWebFluxHandler handler) {
return new CallbackWebFluxRouter(handler).routes();
}
}
@@ -0,0 +1,30 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive;
import java.util.Objects;
import org.springframework.web.reactive.function.server.RequestPredicates;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
/**
* Routes the reactive callback path.
*
* <p>Kept separate from the servlet controller so that only one of the two is ever registered; two
* endpoints on the same path would mean a provider's signature is verified twice against two
* different canonical URLs.
*/
public final class CallbackWebFluxRouter {
private final NotificationCallbackWebFluxHandler handler;
public CallbackWebFluxRouter(NotificationCallbackWebFluxHandler handler) {
this.handler = Objects.requireNonNull(handler, "handler");
}
/** Router function for the callback path. */
public RouterFunction<ServerResponse> routes() {
return RouterFunctions.route(
RequestPredicates.POST("/internal/notification/callbacks/{provider}/{profile}"),
handler::handle);
}
}
@@ -0,0 +1,85 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive;
import dev.caskeleton.adapter.inbound.web.notification.platform.callback.CallbackRequestFactory;
import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException;
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.springframework.core.io.buffer.DataBufferLimitException;
import org.springframework.http.HttpStatus;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
/**
* Reactive callback endpoint.
*
* <p>Ingestion is blocking — it writes to the database — so it runs on {@code boundedElastic} and
* never on the event loop. Running it inline would stall every other connection the loop is
* serving.
*
* <p>It shares the canonicalisation and the ingestion service with the servlet endpoint, so a
* deployment can switch web stacks without changing what a provider signature is checked against.
*/
public final class NotificationCallbackWebFluxHandler {
private final ProviderCallbackIngestionService ingestion;
private final CallbackRequestFactory requestFactory;
private final BoundedCallbackBodyReader bodyReader;
public NotificationCallbackWebFluxHandler(
ProviderCallbackIngestionService ingestion,
CallbackRequestFactory requestFactory,
BoundedCallbackBodyReader bodyReader) {
this.ingestion = Objects.requireNonNull(ingestion, "ingestion");
this.requestFactory = Objects.requireNonNull(requestFactory, "requestFactory");
this.bodyReader = Objects.requireNonNull(bodyReader, "bodyReader");
}
/** Handle one callback. */
public Mono<ServerResponse> handle(ServerRequest request) {
String provider = request.pathVariable("provider");
String profile = request.pathVariable("profile");
return bodyReader
.read(request)
.flatMap(
body ->
Mono.fromCallable(
() ->
ingestion.ingest(
requestFactory.create(
provider,
profile,
request.uri().toString(),
request.method().name(),
request.headers().contentType().map(Object::toString),
headers(request),
body)))
.subscribeOn(Schedulers.boundedElastic()))
.then(ServerResponse.noContent().build())
.onErrorResume(
DataBufferLimitException.class,
failure -> ServerResponse.status(HttpStatus.CONTENT_TOO_LARGE).build())
.onErrorResume(
CallbackValidationException.class,
failure -> ServerResponse.status(HttpStatus.BAD_REQUEST).build());
}
private static Map<String, List<String>> headers(ServerRequest request) {
Map<String, List<String>> headers = new java.util.LinkedHashMap<>();
request
.headers()
.asHttpHeaders()
.forEach((name, values) -> headers.put(name, List.copyOf(values)));
return Map.copyOf(headers);
}
/** Content type of a request, if declared. */
public static Optional<String> contentType(ServerRequest request) {
return request.headers().contentType().map(Object::toString);
}
}
@@ -0,0 +1,396 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.callback.AppendEventResult;
import dev.caskeleton.application.notification.platform.callback.CallbackLimits;
import dev.caskeleton.application.notification.platform.callback.CallbackRequest;
import dev.caskeleton.application.notification.platform.callback.CallbackVerificationResult;
import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
import dev.caskeleton.application.notification.platform.callback.ProjectionResult;
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter;
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapterRegistry;
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService;
import dev.caskeleton.application.notification.platform.callback.ProviderEventLedger;
import dev.caskeleton.application.notification.platform.callback.ProviderEventProjectionService;
import dev.caskeleton.application.notification.platform.callback.ProviderEventRecord;
import dev.caskeleton.application.notification.platform.callback.ProviderEventRecordId;
import dev.caskeleton.application.notification.platform.callback.VerifiedCallback;
import dev.caskeleton.application.notification.platform.callback.VerifiedProviderEvent;
import dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort;
import dev.caskeleton.application.notification.platform.observation.NotificationSecurityAuditPort;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
/**
* What the servlet transport is responsible for handing the callback pipeline.
*
* <p>The pipeline itself belongs to application-core and is tested there. What is only testable
* here is the translation: the exact received octets, the externally-visible URL, and headers that
* survive the servlet container's own casing. Each is a common cause of a valid webhook failing
* verification, and none is visible from a unit test of the provider adapter.
*
* <p>The capture point is the provider adapter's {@code verify}, which is the first thing in the
* pipeline to see the whole request. It rejects, so the test never needs a ledger.
*/
class NotificationCallbackMvcControllerTest {
private static final Clock CLOCK =
Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC);
private static final String TRUSTED_PROXY = "10.0.0.1";
private final List<CallbackRequest> verified = new ArrayList<>();
private final List<String> rejections = new ArrayList<>();
private final NotificationCallbackMvcController controller =
new NotificationCallbackMvcController(
new ProviderCallbackIngestionService(
new CapturingRegistry(),
new UnusedLedger(),
// Never reached: verification always fails in this fixture, and the pipeline appends
// only after a valid signature.
new ProviderEventProjectionService(
new UnusedLedger(),
providerId -> java.util.Optional.empty(),
new UnusedAttemptResolver(),
new UnusedProjectionStore(),
(attempt, facts) -> {
throw new UnsupportedOperationException();
},
new UnusedTransactions(),
new DiscardingMetrics()),
new UnusedPayloadProtection(),
new RecordingSecurityAudit(),
new DiscardingMetrics(),
CLOCK),
new CallbackRequestFactory(new ExternalRequestUrlResolver(Set.of(TRUSTED_PROXY)), CLOCK));
@Test
void theExactReceivedOctetsReachTheAdapterUnparsed() {
byte[] body =
"MessageSid=SM1&MessageStatus=delivered&Signed=a+b%2Fc".getBytes(StandardCharsets.UTF_8);
assertThatThrownBy(
() ->
controller.callback(
"twilio", "twilio-primary", request("application/x-www-form-urlencoded"), body))
.isInstanceOf(CallbackValidationException.class);
// Byte for byte, including the percent-encoding a form parse would have consumed and re-encoded
// differently — which is the single most common cause of a valid webhook failing its signature.
assertThat(verified).hasSize(1);
assertThat(verified.get(0).body()).isEqualTo(body);
assertThat(verified.get(0).contentType()).contains("application/x-www-form-urlencoded");
assertThat(verified.get(0).httpMethod()).isEqualTo("POST");
}
@Test
void aForwardedHostFromAnUntrustedPeerIsIgnored() {
var request = request("application/json");
request.setRemoteAddr("203.0.113.9");
request.addHeader("X-Forwarded-Proto", "https");
request.addHeader("X-Forwarded-Host", "attacker.example.com");
assertThatThrownBy(
() ->
controller.callback(
"twilio", "twilio-primary", request, "{}".getBytes(StandardCharsets.UTF_8)))
.isInstanceOf(CallbackValidationException.class);
// Honouring the header unconditionally would let any caller choose the URL that gets verified,
// which defeats the signature entirely.
assertThat(verified.get(0).externalUrl()).doesNotContain("attacker.example.com");
}
@Test
void aForwardedHostFromATrustedProxyBecomesTheCanonicalUrl() {
var request = request("application/json");
request.setRemoteAddr(TRUSTED_PROXY);
request.addHeader("X-Forwarded-Proto", "https");
request.addHeader("X-Forwarded-Host", "callback.example.com");
assertThatThrownBy(
() ->
controller.callback(
"twilio", "twilio-primary", request, "{}".getBytes(StandardCharsets.UTF_8)))
.isInstanceOf(CallbackValidationException.class);
assertThat(verified.get(0).externalUrl())
.isEqualTo(
"https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary");
}
@Test
void headersSurviveTheContainersCasingAndStayAddressableEitherWay() {
var request = request("application/json");
request.addHeader("X-Twilio-Signature", "abc123");
assertThatThrownBy(
() ->
controller.callback(
"twilio", "twilio-primary", request, "{}".getBytes(StandardCharsets.UTF_8)))
.isInstanceOf(CallbackValidationException.class);
assertThat(verified.get(0).header("x-twilio-signature")).contains("abc123");
assertThat(verified.get(0).header("X-TWILIO-SIGNATURE")).contains("abc123");
}
@Test
void aBodyOverTheTransportCeilingIsRefusedBeforeAnyAdapterIsConsulted() {
byte[] oversized = new byte[NotificationCallbackMvcController.MAX_BODY_BYTES + 1];
var response =
controller.callback("twilio", "twilio-primary", request("application/json"), oversized);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONTENT_TOO_LARGE);
// Nothing downstream sees it, so no signature check ever runs over an attacker-sized payload.
assertThat(verified).isEmpty();
}
@Test
void aRejectedCallbackRevealsNothingBeyondTheStatusCode() {
var response =
controller.onValidationFailure(
new CallbackValidationException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.CALLBACK_SIGNATURE_INVALID,
FailureCategory.CALLBACK_VALIDATION_FAILURE)));
// The endpoint is unauthenticated by design — the signature is the authentication — so an error
// body is a free oracle for whoever is probing it.
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).isNull();
}
@Test
void aRejectedSignatureIsRecordedAsASecurityEventRatherThanADeliveryEvent() {
assertThatThrownBy(
() ->
controller.callback(
"twilio",
"twilio-primary",
request("application/json"),
"{}".getBytes(StandardCharsets.UTF_8)))
.isInstanceOf(CallbackValidationException.class);
// Writing it to the ledger would let anyone who can reach the endpoint fill a recipient's
// delivery history with noise.
assertThat(rejections).containsExactly("SIGNATURE_MISMATCH");
}
private static MockHttpServletRequest request(String contentType) {
var request =
new MockHttpServletRequest(
"POST", "/internal/notification/callbacks/twilio/twilio-primary");
request.setContentType(contentType);
return request;
}
/** Registry whose adapter records the request and then refuses it. */
private final class CapturingRegistry implements ProviderCallbackAdapterRegistry {
@Override
public ProviderCallbackAdapter require(ProviderProfileId profileId) {
return new ProviderCallbackAdapter() {
@Override
public ProviderId providerId() {
return new ProviderId("twilio");
}
@Override
public CallbackVerificationResult verify(CallbackRequest request) {
verified.add(request);
return CallbackVerificationResult.invalid("SIGNATURE_MISMATCH");
}
@Override
public List<NormalizedProviderEvent> normalize(VerifiedCallback callback) {
throw new UnsupportedOperationException("verification always fails in this fixture");
}
};
}
@Override
public CallbackLimits limitsFor(ProviderProfileId profileId) {
return new CallbackLimits(
65_536L, Set.of("application/json", "application/x-www-form-urlencoded"));
}
}
/** Security audit that keeps the rejection reason. */
private final class RecordingSecurityAudit implements NotificationSecurityAuditPort {
@Override
public void callbackSignatureRejected(ProviderProfileId profileId, String reasonCode) {
rejections.add(reasonCode);
}
@Override
public void callbackRejectedByLimit(ProviderProfileId profileId, String reasonCode) {
rejections.add(reasonCode);
}
}
/** Metrics are exercised elsewhere; discarding them keeps this test about the transport. */
private static final class DiscardingMetrics implements NotificationMetricsPort {
@Override
public void increment(String metricName, Map<String, String> tags) {
// Intentionally empty.
}
@Override
public void record(String metricName, Map<String, String> tags, Duration value) {
// Intentionally empty.
}
@Override
public void gauge(String metricName, Map<String, String> tags, double value) {
// Intentionally empty.
}
}
/** Never reached: attempt correlation happens only for an accepted callback. */
private static final class UnusedAttemptResolver
implements dev.caskeleton.application.notification.platform.callback
.DeliveryAttemptResolverPort {
@Override
public java.util.Optional<
dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot>
byAttemptId(DeliveryAttemptId attemptId) {
return java.util.Optional.empty();
}
@Override
public java.util.Optional<
dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot>
byProviderRequestId(ProviderProfileId profileId, String providerRequestIdHash) {
return java.util.Optional.empty();
}
}
/** Never reached: projection runs only after a signature has been accepted. */
private static final class UnusedProjectionStore
implements dev.caskeleton.application.notification.platform.callback
.DeliveryProjectionStorePort {
@Override
public dev.caskeleton.application.notification.platform.callback.DeliveryProjection load(
DeliveryAttemptId attemptId) {
throw new UnsupportedOperationException();
}
@Override
public void save(
DeliveryAttemptId attemptId,
dev.caskeleton.application.notification.platform.callback.DeliveryProjection projection) {
throw new UnsupportedOperationException();
}
}
/** Never reached: nothing in this fixture gets as far as a transaction. */
private static final class UnusedTransactions
implements dev.caskeleton.application.transaction.TransactionPort {
@Override
public <T> T inWrite(java.util.function.Supplier<T> action) {
throw new UnsupportedOperationException();
}
@Override
public <T> T inRootWrite(java.util.function.Supplier<T> action) {
throw new UnsupportedOperationException();
}
@Override
public <T> T inRead(java.util.function.Supplier<T> action) {
throw new UnsupportedOperationException();
}
@Override
public <T> T inNew(java.util.function.Supplier<T> action) {
throw new UnsupportedOperationException();
}
}
/** Never reached: every request in this fixture is rejected before the payload is retained. */
private static final class UnusedPayloadProtection
implements dev.caskeleton.application.notification.platform.callback
.CallbackPayloadProtectionPort {
@Override
public byte[] protectRawPayload(byte[] rawBody) {
throw new UnsupportedOperationException();
}
@Override
public String digest(byte[] rawBody) {
throw new UnsupportedOperationException();
}
@Override
public String fingerprint(
ProviderProfileId profileId, NormalizedProviderEvent event, String rawPayloadDigest) {
throw new UnsupportedOperationException();
}
}
/** Never reached: every request in this fixture is rejected before the append. */
private static final class UnusedLedger implements ProviderEventLedger {
@Override
public AppendEventResult append(VerifiedProviderEvent event) {
throw new UnsupportedOperationException();
}
@Override
public AppendEventResult appendAll(List<VerifiedProviderEvent> events) {
throw new UnsupportedOperationException();
}
@Override
public List<ProviderEventRecord> pendingProjection(int limit) {
throw new UnsupportedOperationException();
}
@Override
public void markApplied(ProviderEventRecordId eventId, ProjectionResult result) {
throw new UnsupportedOperationException();
}
@Override
public void markFailed(ProviderEventRecordId eventId, String errorCode) {
throw new UnsupportedOperationException();
}
@Override
public List<ProviderEventRecord> unmatched(int limit) {
throw new UnsupportedOperationException();
}
@Override
public List<ProviderEventRecord> eventsForAttempt(DeliveryAttemptId attemptId) {
throw new UnsupportedOperationException();
}
}
}
@@ -6,6 +6,34 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.springframework:spring-web' // Slack webhook client (RestClient)
implementation 'org.slf4j:slf4j-api'
// Notification Delivery Platform.
// - mail: the SMTP provider adapter is built on JavaMailSender/MimeMessageHelper, which is where
// multipart/alternative, inline resources and header validation already live. Rebuilding MIME
// by hand to avoid one dependency would be the more dangerous choice.
// - jackson-databind: provider payloads, callback bodies and the canonical variables payload are
// JSON. It stays inside this adapter; application-core never sees a JSON type.
// - reactor-core: only the optional Reactor facade uses it. The core async type stays
// CompletionStage, so nothing else on this classpath depends on Reactor.
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-json'
implementation 'io.projectreactor:reactor-core'
// JSON Schema 2020-12 validation of template variables, using the same validator and version the
// messaging adapter already depends on rather than a second implementation of the same spec.
// The YAML dataformat is excluded: schemas are supplied as JSON strings, so pulling a YAML
// parser onto the runtime classpath would add attack surface for a format nothing reads.
// Thymeleaf is the reference HTML renderer, added as the engine only — not the Spring
// starter, which would drag a view resolver and a servlet integration onto an outbound
// adapter that renders strings and never serves a request.
implementation 'org.thymeleaf:thymeleaf'
implementation('com.networknt:json-schema-validator:3.0.2') {
exclude group: 'tools.jackson.dataformat', module: 'jackson-dataformat-yaml'
exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml'
}
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
testImplementation 'io.projectreactor:reactor-test'
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
@@ -1,23 +1,24 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.ethlo.time:itu:1.14.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
@@ -32,6 +33,7 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnno
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.networknt:json-schema-validator:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
@@ -43,8 +45,11 @@ io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnota
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-test:3.8.0=testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.mail:jakarta.mail-api:2.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
@@ -53,6 +58,7 @@ net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
ognl:ognl:3.3.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
@@ -60,9 +66,9 @@ org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
@@ -73,14 +79,18 @@ org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,test
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
org.attoparser:attoparser:2.0.7.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.eclipse.angus:angus-activation:2.0.3=runtimeClasspath,testRuntimeClasspath
org.eclipse.angus:angus-mail:2.0.5=runtimeClasspath,testRuntimeClasspath
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.javassist:javassist:3.29.0-GA=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
@@ -95,10 +105,10 @@ org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeCla
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
@@ -106,28 +116,32 @@ org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-mail:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-json:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-mail:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
@@ -137,16 +151,19 @@ org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRunti
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context-support:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.thymeleaf:thymeleaf:3.1.3.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.unbescape:unbescape:1.1.6.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.outbound.notification.platform.admin;
import dev.caskeleton.application.notification.platform.admin.AdminAccessDeniedException;
import dev.caskeleton.application.notification.platform.admin.AdminActor;
import dev.caskeleton.application.notification.platform.admin.NotificationAdminAuthority;
import dev.caskeleton.application.notification.platform.api.TenantId;
import java.util.Objects;
import java.util.Optional;
/**
* Operator authority check.
*
* <p>Application authority never grants an operator authority. The two planes are separated so that
* a compromised application credential cannot redrive a message or lift a suppression — the actions
* whose whole purpose is to override the platform's own safety decisions.
*/
public final class AdminAuthorizationGuard {
/** Require an authority, or refuse. */
public void require(AdminActor actor, NotificationAdminAuthority authority) {
Objects.requireNonNull(actor, "actor");
Objects.requireNonNull(authority, "authority");
if (!actor.holds(authority)) {
throw new AdminAccessDeniedException(authority);
}
}
/**
* Require that the actor may act on a tenant.
*
* <p>An actor with no tenant is a global operator; one bound to a tenant may only act inside it.
*/
public void requireTenant(AdminActor actor, TenantId tenantId) {
Objects.requireNonNull(actor, "actor");
Objects.requireNonNull(tenantId, "tenantId");
Optional<TenantId> scope = actor.tenantId();
if (scope.isPresent() && !scope.get().equals(tenantId)) {
throw new AdminAccessDeniedException(NotificationAdminAuthority.SUPPRESS);
}
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.notification.platform.admin;
import dev.caskeleton.application.notification.platform.admin.DuplicateRiskApprovalRequiredException;
import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation;
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
import java.util.Objects;
/**
* Blocks an unapproved redrive of an ambiguous attempt.
*
* <p>The platform cannot tell whether the first submission reached the user, so re-sending is a
* decision with a real cost that only a human can accept. Requiring the approval flag makes that
* acceptance an explicit, audited act rather than a default.
*/
public final class DuplicateRiskGuard {
/** Verify the operator accepted the duplicate risk when one exists. */
public void verify(DeliveryAttemptSnapshot attempt, boolean approved) {
Objects.requireNonNull(attempt, "attempt");
boolean risky =
attempt.confirmation() == AttemptConfirmation.AMBIGUOUS
|| attempt.submissionOutcome()
== dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome
.CONFIRMED_ACCEPTED;
if (risky && !approved) {
throw new DuplicateRiskApprovalRequiredException();
}
}
}
@@ -0,0 +1,325 @@
package dev.caskeleton.adapter.outbound.notification.platform.admin;
import dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntimeRegistry;
import dev.caskeleton.application.notification.platform.admin.AdminActor;
import dev.caskeleton.application.notification.platform.admin.AdminOperationResult;
import dev.caskeleton.application.notification.platform.admin.AdminOperationStorePort;
import dev.caskeleton.application.notification.platform.admin.NotificationAdminAuthority;
import dev.caskeleton.application.notification.platform.admin.NotificationAdminService;
import dev.caskeleton.application.notification.platform.admin.ReconcileCommand;
import dev.caskeleton.application.notification.platform.admin.RedriveCommand;
import dev.caskeleton.application.notification.platform.admin.SetProviderStateCommand;
import dev.caskeleton.application.notification.platform.admin.SuppressCommand;
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState;
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptStorePort;
import dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort;
import dev.caskeleton.application.notification.platform.dispatch.ReconciliationService;
import dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent;
import dev.caskeleton.application.notification.platform.observation.NotificationAuditPort;
import dev.caskeleton.application.notification.platform.policy.SuppressionEntry;
import dev.caskeleton.application.notification.platform.policy.SuppressionId;
import dev.caskeleton.application.notification.platform.policy.SuppressionSource;
import dev.caskeleton.application.notification.platform.policy.SuppressionStorePort;
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
import dev.caskeleton.application.transaction.TransactionPort;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
/**
* N4 operator plane.
*
* <p>Four properties hold for every operation: a separate authority, an idempotent operation id, a
* recorded reason, and an audit row. The idempotency matters more than it looks — an operator
* retrying a redrive after a timeout must not send the message twice, which is exactly the failure
* the operation is trying to repair.
*
* <p>A dry run reads and reports but writes nothing, so an operator can see the blast radius of a
* bulk action before committing to it.
*/
public final class NotificationAdminServiceImpl implements NotificationAdminService {
private final AdminAuthorizationGuard authorization;
private final DuplicateRiskGuard duplicateRiskGuard;
private final DeliveryAttemptStorePort attempts;
private final RecipientDeliveryStorePort recipients;
private final ReconciliationService reconciliation;
private final SuppressionStorePort suppressions;
private final ProviderRuntimeRegistry runtimes;
private final AdminOperationStorePort operations;
private final NotificationAuditPort audit;
private final TransactionPort transactions;
private final Clock clock;
public NotificationAdminServiceImpl(
AdminAuthorizationGuard authorization,
DuplicateRiskGuard duplicateRiskGuard,
DeliveryAttemptStorePort attempts,
RecipientDeliveryStorePort recipients,
ReconciliationService reconciliation,
SuppressionStorePort suppressions,
ProviderRuntimeRegistry runtimes,
AdminOperationStorePort operations,
NotificationAuditPort audit,
TransactionPort transactions,
Clock clock) {
this.authorization = Objects.requireNonNull(authorization, "authorization");
this.duplicateRiskGuard = Objects.requireNonNull(duplicateRiskGuard, "duplicateRiskGuard");
this.attempts = Objects.requireNonNull(attempts, "attempts");
this.recipients = Objects.requireNonNull(recipients, "recipients");
this.reconciliation = Objects.requireNonNull(reconciliation, "reconciliation");
this.suppressions = Objects.requireNonNull(suppressions, "suppressions");
this.runtimes = Objects.requireNonNull(runtimes, "runtimes");
this.operations = Objects.requireNonNull(operations, "operations");
this.audit = Objects.requireNonNull(audit, "audit");
this.transactions = Objects.requireNonNull(transactions, "transactions");
this.clock = Objects.requireNonNull(clock, "clock");
}
@Override
public AdminOperationResult redrive(RedriveCommand command, AdminActor actor) {
Objects.requireNonNull(command, "command");
authorization.require(actor, NotificationAdminAuthority.REDRIVE);
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
if (replayed.isPresent()) {
return replayed.get();
}
DeliveryAttemptSnapshot original =
attempts
.snapshot(command.attemptId())
.orElseThrow(() -> new IllegalStateException("delivery attempt is not available"));
authorization.requireTenant(actor, original.tenantId());
duplicateRiskGuard.verify(original, command.approveDuplicateRisk());
if (command.dryRun()) {
return new AdminOperationResult(
command.operationId(),
true,
1,
Optional.of(original.notificationId()),
Optional.of(original.recipientDeliveryId()),
Optional.empty(),
List.of("DRY_RUN"));
}
return transactions.inWrite(
() -> {
// The logical identities are preserved and only the attempt is new, so the history stays
// one story rather than becoming two unrelated notifications.
recipients.transition(
original.recipientDeliveryId(),
RecipientDeliveryState.READY_TO_DISPATCH,
Optional.of(clock.instant()));
AdminOperationResult result =
new AdminOperationResult(
command.operationId(),
false,
1,
Optional.of(original.notificationId()),
Optional.of(original.recipientDeliveryId()),
Optional.empty(),
List.of(command.reason()));
audit.record(
new NotificationAuditEvent(
"ADMIN_REDRIVE",
actor.actorRef(),
Optional.of(command.reason()),
Optional.of(command.operationId()),
clock.instant(),
Map.of(
"provider", original.providerId().value(),
"channel", original.channel().name())));
return operations.save(result, actor, "ADMIN_REDRIVE");
});
}
@Override
public AdminOperationResult reconcile(ReconcileCommand command, AdminActor actor) {
Objects.requireNonNull(command, "command");
authorization.require(actor, NotificationAdminAuthority.RECONCILE);
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
if (replayed.isPresent()) {
return replayed.get();
}
if (command.dryRun()) {
return new AdminOperationResult(
command.operationId(),
true,
command.attemptIds().size(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of("DRY_RUN"));
}
List<String> reasons = new ArrayList<>();
int reconciled = 0;
for (DeliveryAttemptId attemptId : command.attemptIds()) {
reconciliation.reconcile(attemptId);
reconciled++;
}
reasons.add(command.reason());
AdminOperationResult result =
new AdminOperationResult(
command.operationId(),
false,
reconciled,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.copyOf(reasons));
audit.record(
new NotificationAuditEvent(
"ADMIN_RECONCILE",
actor.actorRef(),
Optional.of(command.reason()),
Optional.of(command.operationId()),
clock.instant(),
Map.of()));
return operations.save(result, actor, "ADMIN_RECONCILE");
}
@Override
public AdminOperationResult suppress(SuppressCommand command, AdminActor actor) {
Objects.requireNonNull(command, "command");
authorization.require(actor, NotificationAdminAuthority.SUPPRESS);
authorization.requireTenant(actor, command.tenantId());
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
if (replayed.isPresent()) {
return replayed.get();
}
if (command.dryRun()) {
return new AdminOperationResult(
command.operationId(),
true,
1,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of("DRY_RUN"));
}
return transactions.inWrite(
() -> {
int affected;
if (command.remove()) {
// Removal is by fingerprint match rather than by id, because an operator lifting a
// suppression knows the target, not the row identifier the platform assigned.
affected =
suppressions
.activeFor(
command.tenantId(), command.targetFingerprint(), clock.instant())
.stream()
.map(entry -> suppressions.remove(command.tenantId(), entry.id()))
.filter(Optional::isPresent)
.count()
> 0
? 1
: 0;
} else {
suppressions.upsert(
new SuppressionEntry(
new SuppressionId(UUID.randomUUID()),
command.tenantId(),
command.scope(),
command.reason(),
command.targetFingerprint(),
Optional.empty(),
clock.instant(),
command.expiresAt(),
SuppressionSource.ADMIN));
affected = 1;
}
AdminOperationResult result =
new AdminOperationResult(
command.operationId(),
false,
affected,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of(command.reasonText()));
audit.record(
new NotificationAuditEvent(
command.remove() ? "ADMIN_SUPPRESSION_REMOVED" : "ADMIN_SUPPRESSION_ADDED",
actor.actorRef(),
Optional.of(command.reason().name()),
Optional.of(command.operationId()),
clock.instant(),
Map.of()));
return operations.save(
result, actor, command.remove() ? "ADMIN_SUPPRESS_REMOVE" : "ADMIN_SUPPRESS_ADD");
});
}
@Override
public AdminOperationResult setProviderState(SetProviderStateCommand command, AdminActor actor) {
Objects.requireNonNull(command, "command");
authorization.require(actor, NotificationAdminAuthority.PROVIDER_CONTROL);
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
if (replayed.isPresent()) {
return replayed.get();
}
if (command.dryRun()) {
return new AdminOperationResult(
command.operationId(),
true,
1,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of("DRY_RUN"));
}
var runtime = runtimes.current(command.profileId());
switch (command.desiredState()) {
case DISABLED -> runtime.markDisabled();
case DRAINING -> runtime.markDraining();
case HEALTHY -> runtime.markHealthy();
case DEGRADED -> runtime.markDegraded(command.reason());
case THROTTLED -> runtime.markThrottled();
case AUTHENTICATION_FAILED -> runtime.markAuthenticationFailed(command.reason());
}
AdminOperationResult result =
new AdminOperationResult(
command.operationId(),
false,
1,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of(command.reason()));
audit.record(
new NotificationAuditEvent(
"ADMIN_PROVIDER_STATE",
actor.actorRef(),
Optional.of(command.reason()),
Optional.of(command.operationId()),
clock.instant(),
Map.of(
"providerProfile", command.profileId().value(),
"status", command.desiredState().name())));
return operations.save(result, actor, "ADMIN_PROVIDER_STATE");
}
/** Current state of a provider runtime, for the health endpoint. */
public ProviderRuntimeState providerState(
dev.caskeleton.application.notification.platform.api.ProviderProfileId profileId) {
return runtimes.state(profileId);
}
}
@@ -0,0 +1,105 @@
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector;
import dev.caskeleton.adapter.outbound.notification.platform.template.JacksonNotificationVariablesCodec;
import dev.caskeleton.adapter.outbound.notification.platform.template.JsonSchemaVariableValidator;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationTemplateEngine;
import dev.caskeleton.adapter.outbound.notification.platform.template.PlaceholderTemplateEngine;
import dev.caskeleton.adapter.outbound.notification.platform.template.Sha256MessageDigestAdapter;
import dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafStringTemplateEngine;
import dev.caskeleton.application.notification.platform.dispatch.MessageDigestPort;
import dev.caskeleton.application.notification.platform.dispatch.NotificationVariablesCodecPort;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.template.TemplateVariableValidator;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Notification platform wiring.
*
* <p>Everything is opt-in and conditional. The platform contributes no beans unless it is enabled,
* and the contact point protector only appears once a secret provider exists — because a protector
* without keys would fail on the first delivery instead of at startup.
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(NotificationPlatformSettings.class)
@ConditionalOnProperty(
prefix = "ca-skeleton.notification.platform",
name = "enabled",
havingValue = "true")
public class NotificationPlatformAutoConfiguration {
/** Canonical variables codec. */
@Bean
@ConditionalOnMissingBean
public NotificationVariablesCodecPort notificationVariablesCodec() {
return new JacksonNotificationVariablesCodec();
}
/** Request fingerprint hashing. */
@Bean
@ConditionalOnMissingBean
public MessageDigestPort notificationMessageDigest() {
return new Sha256MessageDigestAdapter();
}
/** JSON Schema 2020-12 variable validation. */
@Bean
@ConditionalOnMissingBean
public TemplateVariableValidator notificationTemplateVariableValidator() {
return new JsonSchemaVariableValidator();
}
/**
* Template engine, defaulting to the deterministic placeholder substitution.
*
* <p>Thymeleaf is the opt-in alternative: it escapes by default, which matters for HTML email
* bodies built from application input. The default stays the placeholder engine because it has no
* expression evaluator at all, and an unknown engine name fails the boot rather than quietly
* falling back — a deployment that thought it had escaping and did not is the worse outcome.
*/
@Bean
@ConditionalOnMissingBean
public NotificationTemplateEngine notificationTemplateEngine(
@Value("${ca-skeleton.notification.platform.template.engine:placeholder}") String engine) {
return switch (engine.toLowerCase(java.util.Locale.ROOT)) {
case "placeholder" -> new PlaceholderTemplateEngine();
case "thymeleaf" -> new ThymeleafStringTemplateEngine();
default ->
throw new IllegalArgumentException(
"ca-skeleton.notification.platform.template.engine must be"
+ " 'placeholder' or 'thymeleaf', not '"
+ engine
+ "'");
};
}
/** Contact point protection, only once key material is available. */
@Bean
@ConditionalOnBean(SecretMaterialProvider.class)
@ConditionalOnMissingBean
public ContactPointProtector notificationContactPointProtector(SecretMaterialProvider secrets) {
return new AesGcmContactPointProtector(secrets);
}
/**
* Default provider transport.
*
* <p>Replaced in the composition root when the HTTP Client Platform is bound, which is the
* supported way to reuse its TLS, circuit-breaker and SSRF policy.
*/
@Bean
@ConditionalOnMissingBean
public NotificationHttpGateway notificationHttpGateway() {
return new JdkNotificationHttpGateway(Duration.ofSeconds(2));
}
}
@@ -0,0 +1,154 @@
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Bound notification platform configuration.
*
* <p>Validation happens in the constructor, so a misconfiguration fails the boot rather than
* surfacing as a delivery incident hours later. Everything is bounded: there is no property whose
* value may be "unlimited", because an unbounded queue or payload is a resource failure waiting for
* the first burst.
*/
@ConfigurationProperties("ca-skeleton.notification.platform")
public record NotificationPlatformSettings(
boolean enabled, Dispatch dispatch, Callbacks callbacks, Map<String, Provider> providers) {
public NotificationPlatformSettings {
dispatch = dispatch == null ? Dispatch.defaults() : dispatch;
callbacks = callbacks == null ? Callbacks.defaults() : callbacks;
providers = providers == null ? Map.of() : Map.copyOf(providers);
providers.forEach((id, provider) -> provider.validate(id));
}
/** Dispatch runtime bounds. */
public record Dispatch(
int claimBatchSize,
Duration leaseDuration,
Duration pollInterval,
int maxGlobalConcurrency,
int maxAdditionalAttempts,
Duration maxQueueAge,
boolean allowAmbiguousFallback) {
private static final int MAX_CLAIM_BATCH = 1000;
public Dispatch {
Objects.requireNonNull(leaseDuration, "leaseDuration");
Objects.requireNonNull(pollInterval, "pollInterval");
Objects.requireNonNull(maxQueueAge, "maxQueueAge");
if (claimBatchSize < 1 || claimBatchSize > MAX_CLAIM_BATCH) {
throw new IllegalArgumentException(
"ca-skeleton.notification.platform.dispatch.claim-batch-size must be 1.."
+ MAX_CLAIM_BATCH);
}
if (maxGlobalConcurrency < 1) {
throw new IllegalArgumentException("max-global-concurrency must be positive");
}
if (maxAdditionalAttempts < 0) {
throw new IllegalArgumentException("max-additional-attempts must not be negative");
}
if (leaseDuration.isNegative() || leaseDuration.isZero()) {
throw new IllegalArgumentException("lease-duration must be positive and finite");
}
if (leaseDuration.compareTo(pollInterval) <= 0) {
throw new IllegalArgumentException("lease-duration must exceed poll-interval");
}
if (allowAmbiguousFallback) {
// Refused outright rather than warned about: automatic fallback after an ambiguous
// submission is the configuration that turns an unknown into a guaranteed duplicate.
throw new IllegalArgumentException(
"allow-ambiguous-fallback is not a supported configuration");
}
}
/** Conservative defaults. */
public static Dispatch defaults() {
return new Dispatch(
100, Duration.ofSeconds(30), Duration.ofMillis(250), 128, 3, Duration.ofHours(24), false);
}
}
/** Callback endpoint bounds. */
public record Callbacks(boolean enabled, long maxBodyBytes, Duration replaySkew) {
private static final long MAX_BODY_CEILING = 1_048_576L;
public Callbacks {
Objects.requireNonNull(replaySkew, "replaySkew");
if (maxBodyBytes < 1 || maxBodyBytes > MAX_BODY_CEILING) {
throw new IllegalArgumentException("max-body-bytes must be 1.." + MAX_BODY_CEILING);
}
if (replaySkew.isNegative()) {
throw new IllegalArgumentException("replay-skew must not be negative");
}
}
/** Conservative defaults. */
public static Callbacks defaults() {
return new Callbacks(false, 65_536L, Duration.ofMinutes(5));
}
}
/** One provider profile. */
public record Provider(
String type,
boolean enabled,
String environment,
String credentialProfile,
String topic,
String vapidPublicKey,
String callbackSigningSecretRef,
Duration timeout,
int maxConcurrency,
int ratePerSecond) {
/** Fail the boot when a profile cannot possibly work. */
public void validate(String profileId) {
Objects.requireNonNull(profileId, "profileId");
if (!enabled) {
return;
}
require(type != null && !type.isBlank(), profileId, "type is required");
require(environment != null && !environment.isBlank(), profileId, "environment is required");
require(
credentialProfile != null && !credentialProfile.isBlank(),
profileId,
"credential-profile is required");
require(
timeout != null && !timeout.isNegative() && !timeout.isZero(),
profileId,
"timeout must be positive and finite");
require(maxConcurrency >= 1, profileId, "max-concurrency must be positive");
require(ratePerSecond >= 1, profileId, "rate-limit-per-second must be positive");
switch (type == null ? "" : type.toUpperCase(java.util.Locale.ROOT)) {
case "APNS" ->
require(topic != null && !topic.isBlank(), profileId, "APNs profiles require a topic");
case "WEB_PUSH" ->
require(
vapidPublicKey != null && !vapidPublicKey.isBlank(),
profileId,
"Web Push profiles require a VAPID key");
case "TWILIO", "SES" ->
require(
callbackSigningSecretRef != null && !callbackSigningSecretRef.isBlank(),
profileId,
"callback-capable profiles require a callback signing secret reference");
default -> {
// Providers without extra requirements are already covered by the common checks.
}
}
}
private static void require(boolean condition, String profileId, String message) {
if (!condition) {
throw new IllegalArgumentException(
"notification provider profile '" + profileId + "': " + message);
}
}
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
/**
* A held concurrency slot for one provider attempt.
*
* <p>Closing it is what releases the slot, so every call site uses try-with-resources. The permit
* also carries the credential generation the attempt ran under, which is what makes a rotation
* auditable after the fact.
*/
public interface AttemptPermit extends AutoCloseable {
/** Credential generation this attempt is bound to. */
long generation();
@Override
void close();
}
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
import dev.caskeleton.application.notification.platform.dispatch.ReconciliationGatewayPort;
import dev.caskeleton.application.notification.platform.provider.ReconciliationCapability;
import dev.caskeleton.application.notification.platform.provider.ReconciliationResult;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Routes a reconciliation to the capability that owns the provider.
*
* <p>A profile with no registered capability reports {@code Unsupported} rather than falling back
* to a guess. Inventing a final status for a provider that cannot be queried is precisely the
* behaviour the ambiguity model exists to prevent.
*/
public final class CapabilityReconciliationGateway implements ReconciliationGatewayPort {
private final Map<ProviderProfileId, ReconciliationCapability> capabilities;
private final ProviderRuntimeRegistry runtimes;
public CapabilityReconciliationGateway(
Map<ProviderProfileId, ReconciliationCapability> capabilities,
ProviderRuntimeRegistry runtimes) {
this.capabilities = Map.copyOf(Objects.requireNonNull(capabilities, "capabilities"));
this.runtimes = Objects.requireNonNull(runtimes, "runtimes");
}
@Override
public boolean supports(ProviderProfileId profileId) {
Objects.requireNonNull(profileId, "profileId");
return Optional.ofNullable(capabilities.get(profileId))
.map(capability -> capability.supports(runtimes.current(profileId).profile()))
.orElse(false);
}
@Override
public ReconciliationResult reconcile(DeliveryAttemptSnapshot attempt) {
Objects.requireNonNull(attempt, "attempt");
ReconciliationCapability capability = capabilities.get(attempt.providerProfileId());
if (capability == null) {
return new ReconciliationResult.Unsupported();
}
// The permit is taken so a reconciliation backlog cannot become a second load source during the
// incident that produced it.
try (AttemptPermit permit = runtimes.current(attempt.providerProfileId()).acquireAttempt()) {
return capability.reconcile(attempt).toCompletableFuture().join();
}
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.api.RecipientSpec;
import dev.caskeleton.application.notification.platform.api.TenantId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.api.routing.DeliveryStrategy;
import dev.caskeleton.application.notification.platform.api.routing.ExplicitChannel;
import dev.caskeleton.application.notification.platform.api.routing.OrderedFallback;
import dev.caskeleton.application.notification.platform.dispatch.NotificationRoutePlannerPort;
import dev.caskeleton.application.notification.platform.policy.RouteCandidate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Turns a strategy into an ordered route plan using the configured channel-to-profile map.
*
* <p>A channel with no configured provider, or a recipient with no contact point for it, simply
* produces no candidate. The routing engine then reports {@code NO_ELIGIBLE_ROUTE} rather than the
* dispatcher failing on a null, which is the difference between a diagnosable state and a stack
* trace.
*/
public final class ConfiguredRoutePlanner implements NotificationRoutePlannerPort {
private final Map<Channel, ProviderProfileId> profilesByChannel;
public ConfiguredRoutePlanner(Map<Channel, ProviderProfileId> profilesByChannel) {
this.profilesByChannel =
Map.copyOf(Objects.requireNonNull(profilesByChannel, "profilesByChannel"));
}
@Override
public List<RouteCandidate> plan(
TenantId tenantId, RecipientSpec recipient, DeliveryStrategy strategy) {
Objects.requireNonNull(tenantId, "tenantId");
Objects.requireNonNull(recipient, "recipient");
Objects.requireNonNull(strategy, "strategy");
List<Channel> ordered =
switch (strategy) {
case ExplicitChannel explicit -> List.of(explicit.channel());
case OrderedFallback fallback -> fallback.channels();
};
List<RouteCandidate> routes = new ArrayList<>(ordered.size());
int index = 0;
for (Channel channel : ordered) {
ProviderProfileId profileId = profilesByChannel.get(channel);
if (profileId == null) {
continue;
}
var selector =
recipient.contactPoints().stream()
.filter(candidate -> candidate.channel() == channel)
.findFirst();
if (selector.isEmpty()) {
continue;
}
boolean blocked =
recipient
.channelOverride()
.map(override -> override.blockedChannels().contains(channel))
.orElse(false);
routes.add(
new RouteCandidate(
index++, channel, selector.get().contactPointId(), profileId, !blocked, true));
}
return List.copyOf(routes);
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
/** Verifies a candidate generation before it becomes the current one. */
@FunctionalInterface
public interface CredentialProbe {
/** Return false when the candidate credential is not usable. */
boolean isUsable(ProviderRuntime candidate);
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationException;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
/** Raised when a candidate credential generation fails its probe before any cutover. */
public class CredentialValidationException extends NotificationException {
private static final long serialVersionUID = 1L;
public CredentialValidationException() {
super(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID,
FailureCategory.AUTHENTICATION));
}
}
@@ -0,0 +1,61 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.ContactPointId;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.dispatch.NotificationRoutingPlanCodecPort;
import dev.caskeleton.application.notification.platform.policy.RouteCandidate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import tools.jackson.core.type.TypeReference;
/**
* Route plan encoding.
*
* <p>The plan is frozen at submit time, so this is a snapshot format rather than a view: it stores
* exactly what was decided, including which routes were usable then, and never recomputes.
*/
public final class JacksonRoutingPlanCodec implements NotificationRoutingPlanCodecPort {
@Override
public String encode(List<RouteCandidate> routes) {
Objects.requireNonNull(routes, "routes");
List<Map<String, Object>> encoded = new ArrayList<>(routes.size());
for (RouteCandidate route : routes) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("routeIndex", route.routeIndex());
entry.put("channel", route.channel().name());
entry.put("contactPointId", route.contactPointId().value().toString());
entry.put("providerProfileId", route.providerProfileId().value());
entry.put("contactPointActive", route.contactPointActive());
entry.put("providerEnabled", route.providerEnabled());
encoded.add(entry);
}
return NotificationJsonMapper.mapper().writeValueAsString(encoded);
}
@Override
public List<RouteCandidate> decode(String payload) {
Objects.requireNonNull(payload, "payload");
List<LinkedHashMap<String, Object>> raw =
NotificationJsonMapper.mapper()
.readValue(payload, new TypeReference<ArrayList<LinkedHashMap<String, Object>>>() {});
List<RouteCandidate> routes = new ArrayList<>(raw.size());
for (Map<String, Object> entry : raw) {
routes.add(
new RouteCandidate(
((Number) entry.get("routeIndex")).intValue(),
Channel.valueOf(String.valueOf(entry.get("channel"))),
new ContactPointId(UUID.fromString(String.valueOf(entry.get("contactPointId")))),
new ProviderProfileId(String.valueOf(entry.get("providerProfileId"))),
Boolean.TRUE.equals(entry.get("contactPointActive")),
Boolean.TRUE.equals(entry.get("providerEnabled"))));
}
return List.copyOf(routes);
}
}
@@ -0,0 +1,61 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptStorePort;
import dev.caskeleton.application.notification.platform.dispatch.RecipientLeaseStorePort;
import dev.caskeleton.application.notification.platform.dispatch.ReconciliationService;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
/**
* Recovers deliveries a dead worker left in flight.
*
* <p>An expired lease on a {@code DISPATCHING} delivery is the crash case: the attempt row exists,
* so a provider call may have happened. Recovery therefore reconciles rather than re-dispatching —
* re-dispatching would be the platform choosing to duplicate rather than to ask.
*/
public final class LeaseRecoveryService {
private final RecipientLeaseStorePort leases;
private final DeliveryAttemptStorePort attempts;
private final ReconciliationService reconciliation;
private final Duration staleAfter;
private final int batchSize;
public LeaseRecoveryService(
RecipientLeaseStorePort leases,
DeliveryAttemptStorePort attempts,
ReconciliationService reconciliation,
Duration staleAfter,
int batchSize) {
this.leases = Objects.requireNonNull(leases, "leases");
this.attempts = Objects.requireNonNull(attempts, "attempts");
this.reconciliation = Objects.requireNonNull(reconciliation, "reconciliation");
this.staleAfter = Objects.requireNonNull(staleAfter, "staleAfter");
this.batchSize = batchSize;
if (batchSize < 1) {
throw new IllegalArgumentException("batchSize");
}
if (staleAfter.isNegative() || staleAfter.isZero()) {
throw new IllegalArgumentException("staleAfter must be positive and finite");
}
}
/** Recover one batch of abandoned deliveries; returns how many were handled. */
public int recoverOnce() {
List<dev.caskeleton.application.notification.platform.api.RecipientDeliveryId> abandoned =
leases.expiredDispatching(batchSize, staleAfter);
int handled = 0;
for (var recipientDeliveryId : abandoned) {
for (var attempt : attempts.attemptsOf(recipientDeliveryId)) {
if (attempt.completedAt().isEmpty()) {
DeliveryAttemptId attemptId = attempt.id();
reconciliation.reconcile(attemptId);
handled++;
}
}
}
return handled;
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.inbox.InboxItemCreated;
import dev.caskeleton.application.notification.platform.inbox.NotificationInboxSignalPort;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Default inbox signal sink.
*
* <p>Emits identifiers only, never content. A deployment with a WebSocket or messaging relay
* replaces it; until then the inbox is still complete, because the row — not the signal — is the
* source of truth.
*/
public final class LoggingInboxSignalPublisher implements NotificationInboxSignalPort {
private static final Logger log = LoggerFactory.getLogger("notification.inbox.signal");
@Override
public void publish(InboxItemCreated event) {
Objects.requireNonNull(event, "event");
log.info(
"event=inbox_item_created itemId={} tenant={} category={}",
event.itemId().value(),
event.principal().tenantId().value(),
event.category());
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.dispatch.TemplateRendererRegistry;
import dev.caskeleton.application.notification.platform.template.NotificationTemplateRenderer;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** Channel-to-renderer lookup built at wiring time. */
public final class MapTemplateRendererRegistry implements TemplateRendererRegistry {
private final Map<Channel, NotificationTemplateRenderer> renderers;
public MapTemplateRendererRegistry(List<NotificationTemplateRenderer> renderers) {
Objects.requireNonNull(renderers, "renderers");
Map<Channel, NotificationTemplateRenderer> byChannel = new EnumMap<>(Channel.class);
renderers.forEach(renderer -> byChannel.put(renderer.channel(), renderer));
this.renderers = Map.copyOf(byChannel);
}
@Override
public NotificationTemplateRenderer rendererFor(Channel channel) {
NotificationTemplateRenderer renderer = renderers.get(channel);
if (renderer == null) {
throw new IllegalStateException("no renderer registered for the channel");
}
return renderer;
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import java.time.Duration;
import java.util.Objects;
/**
* Dispatch runtime bounds.
*
* <p>Every field is bounded and validated at construction. "Unlimited" is never an accepted value:
* an unbounded claim batch or queue is how a burst becomes an out-of-memory failure instead of
* backpressure.
*/
public record NotificationDispatchProperties(
int claimBatchSize,
Duration leaseDuration,
Duration pollInterval,
int maxGlobalConcurrency,
int maxAdditionalAttempts,
Duration estimatedDispatchDuration,
Duration maxQueueAge,
Duration shutdownGrace) {
private static final int MAX_CLAIM_BATCH = 1000;
public NotificationDispatchProperties {
Objects.requireNonNull(leaseDuration, "leaseDuration");
Objects.requireNonNull(pollInterval, "pollInterval");
Objects.requireNonNull(estimatedDispatchDuration, "estimatedDispatchDuration");
Objects.requireNonNull(maxQueueAge, "maxQueueAge");
Objects.requireNonNull(shutdownGrace, "shutdownGrace");
if (claimBatchSize < 1 || claimBatchSize > MAX_CLAIM_BATCH) {
throw new IllegalArgumentException("claimBatchSize must be 1.." + MAX_CLAIM_BATCH);
}
if (maxGlobalConcurrency < 1) {
throw new IllegalArgumentException("maxGlobalConcurrency");
}
if (maxAdditionalAttempts < 0) {
throw new IllegalArgumentException("maxAdditionalAttempts");
}
requirePositive(leaseDuration, "leaseDuration");
requirePositive(pollInterval, "pollInterval");
requirePositive(maxQueueAge, "maxQueueAge");
if (leaseDuration.compareTo(pollInterval) <= 0) {
// A lease shorter than the poll interval expires before the worker can renew it, so two
// workers would routinely claim the same job.
throw new IllegalArgumentException("leaseDuration must exceed pollInterval");
}
}
private static void requirePositive(Duration value, String name) {
if (value.isNegative() || value.isZero()) {
throw new IllegalArgumentException(name + " must be positive and finite");
}
}
}
@@ -0,0 +1,137 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.dispatch.NotificationDispatchService;
import dev.caskeleton.application.notification.platform.dispatch.RecipientLease;
import dev.caskeleton.application.notification.platform.dispatch.RecipientLeaseStorePort;
import dev.caskeleton.application.notification.platform.observation.NotificationMetricName;
import dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Claims due deliveries and hands them to the dispatcher.
*
* <p>The worker never calls a provider itself. It claims, submits to a bounded executor, and stops
* claiming the moment shutdown begins — so a rolling restart drains rather than abandoning leases
* that then have to time out.
*
* <p>Claiming is bounded twice over: by the claim batch size and by a global concurrency permit.
* The second bound matters because a slow provider would otherwise let the queue depth become the
* thread count.
*/
public final class NotificationSchedulerWorker implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(NotificationSchedulerWorker.class);
private final RecipientLeaseStorePort leases;
private final NotificationDispatchService dispatcher;
private final NotificationMetricsPort metrics;
private final NotificationDispatchProperties properties;
private final String workerId;
private final ExecutorService dispatchExecutor;
private final Semaphore globalConcurrency;
private final AtomicBoolean running = new AtomicBoolean();
private final AtomicBoolean shuttingDown = new AtomicBoolean();
public NotificationSchedulerWorker(
RecipientLeaseStorePort leases,
NotificationDispatchService dispatcher,
NotificationMetricsPort metrics,
NotificationDispatchProperties properties,
String workerId) {
this.leases = Objects.requireNonNull(leases, "leases");
this.dispatcher = Objects.requireNonNull(dispatcher, "dispatcher");
this.metrics = Objects.requireNonNull(metrics, "metrics");
this.properties = Objects.requireNonNull(properties, "properties");
this.workerId = Objects.requireNonNull(workerId, "workerId");
this.dispatchExecutor = Executors.newVirtualThreadPerTaskExecutor();
this.globalConcurrency = new Semaphore(properties.maxGlobalConcurrency());
}
/** Claim and dispatch one batch. Returns how many deliveries were claimed. */
public int runOnce() {
if (shuttingDown.get()) {
return 0;
}
List<RecipientLease> claimed =
leases.claim(workerId, properties.claimBatchSize(), properties.leaseDuration());
metrics.gauge(NotificationMetricName.QUEUE_DEPTH, Map.of(), claimed.size());
for (RecipientLease lease : claimed) {
globalConcurrency.acquireUninterruptibly();
dispatchExecutor.execute(
() -> {
try {
dispatcher.dispatch(lease);
} catch (RuntimeException failure) {
// The lease is left to expire rather than being released optimistically: a worker
// that
// failed mid-dispatch cannot prove what the provider did.
log.warn(
"notification dispatch failed worker={} reason={}",
workerId,
failure.getClass().getSimpleName());
} finally {
globalConcurrency.release();
}
});
}
return claimed.size();
}
/** Start the polling loop on a dedicated thread. */
public void start() {
if (!running.compareAndSet(false, true)) {
return;
}
Thread.ofVirtual()
.name("notification-scheduler-" + workerId)
.start(
() -> {
while (running.get() && !shuttingDown.get()) {
try {
if (runOnce() == 0) {
Thread.sleep(properties.pollInterval().toMillis());
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return;
} catch (RuntimeException failure) {
log.warn(
"notification scheduler tick failed worker={} reason={}",
workerId,
failure.getClass().getSimpleName());
}
}
});
}
@Override
public void close() {
shuttingDown.set(true);
running.set(false);
dispatchExecutor.shutdown();
try {
if (!dispatchExecutor.awaitTermination(
properties.shutdownGrace().toMillis(), TimeUnit.MILLISECONDS)) {
dispatchExecutor.shutdownNow();
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
dispatchExecutor.shutdownNow();
}
}
/** Whether the worker has stopped claiming new work. */
public boolean shuttingDown() {
return shuttingDown.get();
}
}
@@ -0,0 +1,73 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.api.error.ProviderUnavailableException;
import java.time.Clock;
import java.util.Objects;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicLong;
/**
* Per-provider rate and concurrency guard.
*
* <p>Tokens are spent on real attempts only. A delivery waiting out its backoff holds no permit,
* because a provider outage would otherwise pin the whole concurrency budget on deliveries that are
* not doing anything.
*/
public final class ProviderAttemptLimiter {
private final Semaphore concurrency;
private final int maxConcurrency;
private final int ratePerSecond;
private final Clock clock;
private final AtomicLong windowStartSecond = new AtomicLong();
private final AtomicLong issuedInWindow = new AtomicLong();
public ProviderAttemptLimiter(int maxConcurrency, int ratePerSecond, Clock clock) {
if (maxConcurrency < 1) {
throw new IllegalArgumentException("maxConcurrency");
}
if (ratePerSecond < 1) {
throw new IllegalArgumentException("ratePerSecond");
}
this.concurrency = new Semaphore(maxConcurrency);
this.maxConcurrency = maxConcurrency;
this.ratePerSecond = ratePerSecond;
this.clock = Objects.requireNonNull(clock, "clock");
this.windowStartSecond.set(clock.instant().getEpochSecond());
}
/** Acquire one attempt slot, or fail fast when the provider budget is spent. */
public void acquire() {
long second = clock.instant().getEpochSecond();
long windowStart = windowStartSecond.get();
if (second != windowStart && windowStartSecond.compareAndSet(windowStart, second)) {
issuedInWindow.set(0L);
}
if (issuedInWindow.incrementAndGet() > ratePerSecond) {
throw unavailable();
}
if (!concurrency.tryAcquire()) {
issuedInWindow.decrementAndGet();
throw unavailable();
}
}
/** Release a previously acquired slot. */
public void release() {
concurrency.release();
}
/** Slots currently held. */
public int activeAttempts() {
return maxConcurrency - concurrency.availablePermits();
}
private static ProviderUnavailableException unavailable() {
return new ProviderUnavailableException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.PROVIDER_UNAVAILABLE, FailureCategory.CAPACITY_REJECTED));
}
}
@@ -0,0 +1,136 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.api.error.ProviderUnavailableException;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot;
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
/**
* One immutable credential generation of a provider.
*
* <p>Generations are replaced, never mutated. Rotating a key by editing a live client would leave
* in-flight calls half-way between two credentials; replacing the whole runtime and letting the old
* one drain keeps every attempt attributable to exactly one generation.
*
* <p>An authentication failure moves the whole runtime, not the message. One expired credential
* multiplied by a queue of notifications is a self-inflicted outage, so the route opens once and
* raises an operational alert instead.
*/
public final class ProviderRuntime {
private final ProviderProfileSnapshot profile;
private final NotificationProviderAdapter adapter;
private final ProviderAttemptLimiter limiter;
private final AtomicReference<ProviderRuntimeState> state;
private final AtomicReference<String> unhealthyReason = new AtomicReference<>();
public ProviderRuntime(
ProviderProfileSnapshot profile,
NotificationProviderAdapter adapter,
ProviderAttemptLimiter limiter) {
this.profile = Objects.requireNonNull(profile, "profile");
this.adapter = Objects.requireNonNull(adapter, "adapter");
this.limiter = Objects.requireNonNull(limiter, "limiter");
this.state = new AtomicReference<>(ProviderRuntimeState.HEALTHY);
}
/** Profile snapshot including the credential generation. */
public ProviderProfileSnapshot profile() {
return profile;
}
/** Credential generation of this runtime. */
public long generation() {
return profile.credentialGeneration();
}
/** Provider adapter bound to this generation. */
public NotificationProviderAdapter adapter() {
return adapter;
}
/** Current health. */
public ProviderRuntimeState state() {
return state.get();
}
/** Why the runtime is unhealthy, if it is. */
public Optional<String> unhealthyReason() {
return Optional.ofNullable(unhealthyReason.get());
}
/** Attempts currently in flight on this generation. */
public int activeAttempts() {
return limiter.activeAttempts();
}
/**
* Acquire a permit for one attempt.
*
* <p>The health check happens before the limiter, so a disabled or failed provider never consumes
* a token it cannot use.
*/
public AttemptPermit acquireAttempt() {
ProviderRuntimeState current = state.get();
if (!current.admitsNewAttempts()) {
throw new ProviderUnavailableException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.PROVIDER_UNAVAILABLE,
current == ProviderRuntimeState.AUTHENTICATION_FAILED
? FailureCategory.AUTHENTICATION
: FailureCategory.CAPACITY_REJECTED));
}
limiter.acquire();
return new LimiterPermit(profile.credentialGeneration(), limiter);
}
/** Mark the credential as rejected by the provider. */
public void markAuthenticationFailed(String reasonCode) {
unhealthyReason.set(Objects.requireNonNull(reasonCode, "reasonCode"));
state.set(ProviderRuntimeState.AUTHENTICATION_FAILED);
}
/** Mark the provider as rate limited. */
public void markThrottled() {
state.compareAndSet(ProviderRuntimeState.HEALTHY, ProviderRuntimeState.THROTTLED);
}
/** Mark the provider as degraded but still usable. */
public void markDegraded(String reasonCode) {
unhealthyReason.set(reasonCode);
state.compareAndSet(ProviderRuntimeState.HEALTHY, ProviderRuntimeState.DEGRADED);
}
/** Return to healthy after a successful attempt. */
public void markHealthy() {
unhealthyReason.set(null);
state.compareAndSet(ProviderRuntimeState.THROTTLED, ProviderRuntimeState.HEALTHY);
state.compareAndSet(ProviderRuntimeState.DEGRADED, ProviderRuntimeState.HEALTHY);
}
/** Stop admitting new attempts; in-flight attempts finish. */
public void markDraining() {
state.set(ProviderRuntimeState.DRAINING);
}
/** Operator disable. */
public void markDisabled() {
state.set(ProviderRuntimeState.DISABLED);
}
/** A permit that releases exactly one limiter slot. */
private record LimiterPermit(long generation, ProviderAttemptLimiter limiter)
implements AttemptPermit {
@Override
public void close() {
limiter.release();
}
}
}
@@ -0,0 +1,78 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
/** Holds the current generation of every provider profile plus the generations still draining. */
public final class ProviderRuntimeRegistry {
private final Map<ProviderProfileId, ProviderRuntime> current = new ConcurrentHashMap<>();
private final Map<ProviderProfileId, CopyOnWriteArrayList<ProviderRuntime>> draining =
new ConcurrentHashMap<>();
/** Register the first generation of a profile. */
public void register(ProviderRuntime runtime) {
Objects.requireNonNull(runtime, "runtime");
current.put(runtime.profile().profileId(), runtime);
}
/** Current generation, or a configuration failure when the profile is unknown. */
public ProviderRuntime current(ProviderProfileId profileId) {
ProviderRuntime runtime = current.get(profileId);
if (runtime == null) {
throw new IllegalStateException("no provider runtime registered for the profile");
}
return runtime;
}
/** Current generation if registered. */
public Optional<ProviderRuntime> find(ProviderProfileId profileId) {
return Optional.ofNullable(current.get(profileId));
}
/**
* Swap in a new generation and start draining the old one.
*
* <p>New dispatches immediately use the new generation while the previous one finishes what it
* already started, which is what makes a credential rotation invisible to callers.
*/
public Optional<ProviderRuntime> replace(ProviderRuntime replacement) {
Objects.requireNonNull(replacement, "replacement");
ProviderProfileId profileId = replacement.profile().profileId();
ProviderRuntime previous = current.put(profileId, replacement);
if (previous != null) {
previous.markDraining();
draining.computeIfAbsent(profileId, key -> new CopyOnWriteArrayList<>()).add(previous);
forgetIfDrained(profileId);
}
return Optional.ofNullable(previous);
}
/** Generations that are draining and still have work in flight. */
public List<ProviderRuntime> drainingGenerations(ProviderProfileId profileId) {
forgetIfDrained(profileId);
return List.copyOf(draining.getOrDefault(profileId, new CopyOnWriteArrayList<>()));
}
/** Health of the current generation. */
public ProviderRuntimeState state(ProviderProfileId profileId) {
return current(profileId).state();
}
private void forgetIfDrained(ProviderProfileId profileId) {
CopyOnWriteArrayList<ProviderRuntime> generations = draining.get(profileId);
if (generations == null) {
return;
}
generations.removeIf(runtime -> runtime.activeAttempts() == 0);
if (generations.isEmpty()) {
draining.remove(profileId);
}
}
}
@@ -0,0 +1,69 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent;
import dev.caskeleton.application.notification.platform.observation.NotificationAuditPort;
import java.time.Clock;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Credential and certificate rotation.
*
* <p>The candidate is probed <em>before</em> the swap. Validating after cutover would mean a typo
* in a rotated secret takes the provider down and only then tells anyone; validating first makes a
* bad candidate a no-op that leaves the working generation in place.
*
* <p>Only the generation and key id reach the audit trail — never the credential material itself.
*/
public final class ProviderRuntimeRotator {
private final ProviderRuntimeRegistry registry;
private final CredentialProbe probe;
private final RuntimeDrainCoordinator drainCoordinator;
private final NotificationAuditPort audit;
private final Clock clock;
private final Duration drainTimeout;
public ProviderRuntimeRotator(
ProviderRuntimeRegistry registry,
CredentialProbe probe,
RuntimeDrainCoordinator drainCoordinator,
NotificationAuditPort audit,
Clock clock,
Duration drainTimeout) {
this.registry = Objects.requireNonNull(registry, "registry");
this.probe = Objects.requireNonNull(probe, "probe");
this.drainCoordinator = Objects.requireNonNull(drainCoordinator, "drainCoordinator");
this.audit = Objects.requireNonNull(audit, "audit");
this.clock = Objects.requireNonNull(clock, "clock");
this.drainTimeout = Objects.requireNonNull(drainTimeout, "drainTimeout");
}
/** Cut over to a new credential generation. */
public void rotate(ProviderProfileId profileId, ProviderRuntime candidate) {
Objects.requireNonNull(profileId, "profileId");
Objects.requireNonNull(candidate, "candidate");
if (!candidate.profile().profileId().equals(profileId)) {
throw new IllegalArgumentException("candidate belongs to a different profile");
}
if (!probe.isUsable(candidate)) {
throw new CredentialValidationException();
}
Optional<ProviderRuntime> previous = registry.replace(candidate);
audit.record(
new NotificationAuditEvent(
"PROVIDER_CREDENTIAL_ROTATION",
"system",
Optional.of("ROTATION"),
Optional.empty(),
clock.instant(),
Map.of(
"providerProfile", profileId.value(),
"generation", Long.toString(candidate.generation()))));
previous.ifPresent(runtime -> drainCoordinator.drain(runtime, drainTimeout));
}
}
@@ -0,0 +1,76 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.dispatch.ProviderDispatchGatewayPort;
import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot;
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import java.util.Objects;
import java.util.concurrent.CompletionException;
/**
* The single outbound call, wrapped in a permit.
*
* <p>The permit is acquired before the call and released in a finally, so a provider that hangs
* consumes exactly one slot and a burst queues rather than exhausting the pool.
*
* <p>A credential rejection is promoted to a runtime state change here rather than being left as a
* per-message failure — one expired key must open the route once, not produce one retry per queued
* notification.
*/
public final class RegistryProviderDispatchGateway implements ProviderDispatchGatewayPort {
private final ProviderRuntimeRegistry runtimes;
public RegistryProviderDispatchGateway(ProviderRuntimeRegistry runtimes) {
this.runtimes = Objects.requireNonNull(runtimes, "runtimes");
}
@Override
public ProviderProfileSnapshot profile(ProviderProfileId profileId) {
return runtimes.current(profileId).profile();
}
@Override
public ProviderRuntimeState state(ProviderProfileId profileId) {
return runtimes.state(profileId);
}
@Override
public ProviderSubmissionResult submit(ProviderSubmission submission) {
Objects.requireNonNull(submission, "submission");
ProviderRuntime runtime = runtimes.current(submission.profile().profileId());
try (AttemptPermit permit = runtime.acquireAttempt()) {
ProviderSubmissionResult result =
runtime.adapter().submit(submission).toCompletableFuture().join();
applyHealth(runtime, result);
return result;
} catch (CompletionException failure) {
// Unwrapped so the dispatcher classifies the real cause rather than the future's wrapper.
Throwable cause = failure.getCause() == null ? failure : failure.getCause();
throw cause instanceof RuntimeException runtimeFailure
? runtimeFailure
: new IllegalStateException("provider submission failed", cause);
}
}
private static void applyHealth(ProviderRuntime runtime, ProviderSubmissionResult result) {
result
.failure()
.ifPresentOrElse(
failure -> {
switch (failure.category()) {
case AUTHENTICATION, AUTHORIZATION ->
runtime.markAuthenticationFailed(failure.code());
case THROTTLED -> runtime.markThrottled();
case TRANSIENT_PROVIDER -> runtime.markDegraded(failure.code());
default -> {
// A message-level failure says nothing about the provider's health.
}
}
},
runtime::markHealthy);
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import java.time.Duration;
import java.util.Objects;
/**
* Waits for a replaced generation to finish its in-flight attempts.
*
* <p>The deadline comes from {@link System#nanoTime()}, not from the injectable clock. A drain
* timeout is a real elapsed-time budget: driving it from a test clock that never advances turns the
* loop into a hang, and driving it from a wall clock makes it sensitive to time adjustments.
*
* <p>Draining is bounded on purpose. A provider that never answers must not hold a credential
* rotation open forever, so after the timeout the generation is abandoned and its attempts follow
* the normal ambiguity and reconciliation path rather than being cancelled mid-flight.
*/
public final class RuntimeDrainCoordinator {
private final Duration pollInterval;
public RuntimeDrainCoordinator(Duration pollInterval) {
this.pollInterval = Objects.requireNonNull(pollInterval, "pollInterval");
if (pollInterval.isNegative() || pollInterval.isZero()) {
throw new IllegalArgumentException("pollInterval");
}
}
/** Drain a generation, returning whether it finished within the timeout. */
public boolean drain(ProviderRuntime runtime, Duration timeout) {
Objects.requireNonNull(runtime, "runtime");
Objects.requireNonNull(timeout, "timeout");
long deadlineNanos = System.nanoTime() + timeout.toNanos();
while (runtime.activeAttempts() > 0) {
if (System.nanoTime() - deadlineNanos >= 0) {
return false;
}
try {
Thread.sleep(pollInterval.toMillis());
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return false;
}
}
return true;
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.TenantId;
import dev.caskeleton.application.notification.platform.dispatch.TenantContextPort;
import java.util.Objects;
/**
* Tenant context for a single-tenant deployment.
*
* <p>A multi-tenant deployment replaces this with a request-scoped implementation. It exists so
* that a single-tenant application still goes through the tenant boundary rather than around it —
* the store queries take a tenant either way, and a deployment that later becomes multi-tenant does
* not have to find every unscoped query.
*/
public final class SingleTenantContext implements TenantContextPort {
private final TenantId tenantId;
public SingleTenantContext(String tenantId) {
this.tenantId = new TenantId(Objects.requireNonNull(tenantId, "tenantId"));
}
@Override
public TenantId currentTenant() {
return tenantId;
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.dispatch.NotificationIdGeneratorPort;
import java.security.SecureRandom;
import java.time.Clock;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
/**
* RFC 9562 UUIDv7.
*
* <p>Time-ordered rather than random because these identifiers are primary keys: a random UUID
* scatters inserts across the whole index, and a notification table takes the highest insert rate
* in the platform.
*
* <p>The monotonic counter guards the case two identifiers are requested inside the same
* millisecond, so ordering holds even under a burst.
*/
public final class UuidV7Generator implements NotificationIdGeneratorPort {
private static final long VERSION_7 = 0x7000L;
private static final long VARIANT_RFC = 0x8000000000000000L;
private final Clock clock;
private final SecureRandom random;
private final AtomicLong lastMillis = new AtomicLong();
private final AtomicLong sequence = new AtomicLong();
public UuidV7Generator(Clock clock) {
this(clock, new SecureRandom());
}
UuidV7Generator(Clock clock, SecureRandom random) {
this.clock = Objects.requireNonNull(clock, "clock");
this.random = Objects.requireNonNull(random, "random");
}
@Override
public UUID nextId() {
long millis = clock.millis();
long previous = lastMillis.getAndSet(millis);
long counter = millis == previous ? sequence.incrementAndGet() : sequence.updateAndGet(x -> 0L);
long high = (millis & 0xFFFFFFFFFFFFL) << 16;
high |= VERSION_7;
high |= counter & 0x0FFFL;
long low = random.nextLong();
low &= 0x3FFFFFFFFFFFFFFFL;
low |= VARIANT_RFC;
return new UUID(high, low);
}
}
@@ -0,0 +1,60 @@
package dev.caskeleton.adapter.outbound.notification.platform.observation;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent;
import dev.caskeleton.application.notification.platform.observation.NotificationAuditPort;
import dev.caskeleton.application.notification.platform.observation.NotificationSecurityAuditPort;
import java.util.Objects;
import java.util.TreeMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Audit sink on a dedicated logger.
*
* <p>Separate from the metrics logger because audit has different retention: a metric may be
* sampled away, while "who lifted this suppression, and why" has to survive.
*
* <p>A rejected callback signature is a security event, not a provider event, so it is recorded
* here and never in the ledger — otherwise anyone who can reach the endpoint could fill a delivery
* history with noise.
*/
public final class LoggingNotificationAudit
implements NotificationAuditPort, NotificationSecurityAuditPort {
private static final Logger audit = LoggerFactory.getLogger("notification.audit");
private static final Logger security = LoggerFactory.getLogger("notification.security");
@Override
public void record(NotificationAuditEvent event) {
Objects.requireNonNull(event, "event");
audit.info(
"action={} actor={} reason={} operationId={} occurredAt={} attributes={}",
event.action(),
event.actorRef(),
event.reasonCode().orElse("-"),
event.operationId().orElse("-"),
event.occurredAt(),
new TreeMap<>(event.boundedAttributes()));
}
@Override
public void callbackSignatureRejected(ProviderProfileId profileId, String reasonCode) {
Objects.requireNonNull(profileId, "profileId");
// The payload is deliberately absent: a forged callback must not get its content into the log
// just by being rejected.
security.warn(
"event=callback_signature_rejected providerProfile={} reason={}",
profileId.value(),
reasonCode);
}
@Override
public void callbackRejectedByLimit(ProviderProfileId profileId, String reasonCode) {
Objects.requireNonNull(profileId, "profileId");
security.warn(
"event=callback_rejected_by_limit providerProfile={} reason={}",
profileId.value(),
reasonCode);
}
}
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.outbound.notification.platform.observation;
import dev.caskeleton.application.notification.platform.observation.CardinalityGuard;
import dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Structured-log metrics sink.
*
* <p>Every tag map passes the cardinality guard before it is emitted, so a stray notification id
* fails here rather than after it has already multiplied a time series into millions of them.
*
* <p>A Micrometer-backed implementation belongs in the composition root, which owns the registry;
* this one keeps the platform usable — and its tag discipline enforced — without one.
*/
public final class LoggingNotificationMetrics implements NotificationMetricsPort {
private static final Logger log = LoggerFactory.getLogger("notification.metrics");
private final CardinalityGuard guard;
public LoggingNotificationMetrics(CardinalityGuard guard) {
this.guard = Objects.requireNonNull(guard, "guard");
}
@Override
public void increment(String metricName, Map<String, String> tags) {
guard.validate(tags);
log.info("metric={} kind=counter tags={}", metricName, ordered(tags));
}
@Override
public void record(String metricName, Map<String, String> tags, Duration value) {
guard.validate(tags);
log.info("metric={} kind=timer millis={} tags={}", metricName, value.toMillis(), ordered(tags));
}
@Override
public void gauge(String metricName, Map<String, String> tags, double value) {
guard.validate(tags);
log.info("metric={} kind=gauge value={} tags={}", metricName, value, ordered(tags));
}
private static Map<String, String> ordered(Map<String, String> tags) {
return new TreeMap<>(tags);
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.notification.platform.observation;
import dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntimeRegistry;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Builds the operational snapshot.
*
* <p>A provider whose credentials were rejected reports unhealthy even though the process is fine:
* that is exactly the condition an operator needs paged on, and it is invisible from process-level
* health.
*/
public final class NotificationHealthReporter {
private final ProviderRuntimeRegistry runtimes;
private final List<ProviderProfileId> monitoredProfiles;
public NotificationHealthReporter(
ProviderRuntimeRegistry runtimes, List<ProviderProfileId> monitoredProfiles) {
this.runtimes = Objects.requireNonNull(runtimes, "runtimes");
this.monitoredProfiles = List.copyOf(Objects.requireNonNull(monitoredProfiles, "profiles"));
}
/** Current snapshot. */
public NotificationHealthSnapshot snapshot() {
List<NotificationHealthSnapshot.ProviderHealth> providers = new ArrayList<>();
boolean healthy = true;
for (ProviderProfileId profileId : monitoredProfiles) {
var runtime = runtimes.find(profileId);
if (runtime.isEmpty()) {
healthy = false;
providers.add(
new NotificationHealthSnapshot.ProviderHealth(profileId.value(), "UNREGISTERED", 0, 0));
continue;
}
ProviderRuntimeState state = runtime.get().state();
if (state == ProviderRuntimeState.AUTHENTICATION_FAILED
|| state == ProviderRuntimeState.DISABLED) {
healthy = false;
}
providers.add(
new NotificationHealthSnapshot.ProviderHealth(
profileId.value(),
state.name(),
runtime.get().generation(),
runtime.get().activeAttempts()));
}
return new NotificationHealthSnapshot(healthy, providers, Map.of());
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.outbound.notification.platform.observation;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Operational view of the platform.
*
* <p>Provider states, credential generations and queue age — nothing else. A health endpoint is one
* of the least protected surfaces an application exposes, so a sender address or a credential
* reference appearing here would be a leak with a wide audience.
*/
public record NotificationHealthSnapshot(
boolean healthy, List<ProviderHealth> providers, Map<String, Long> queue) {
public NotificationHealthSnapshot {
providers = List.copyOf(Objects.requireNonNull(providers, "providers"));
queue = Map.copyOf(Objects.requireNonNull(queue, "queue"));
}
/** One provider runtime's state. */
public record ProviderHealth(
String profileId, String state, long credentialGeneration, int activeAttempts) {
public ProviderHealth {
Objects.requireNonNull(profileId, "profileId");
Objects.requireNonNull(state, "state");
}
}
}
@@ -0,0 +1,100 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.provider.ProviderExecutionEvidence;
import dev.caskeleton.application.notification.platform.provider.ProviderFailure;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import java.time.Duration;
import java.util.Optional;
/**
* Shared translation from a transport failure into an evidence-carrying result.
*
* <p>Every HTTP provider adapter routes its transport failures through here, so the rule that
* "committed body plus no response equals ambiguous" is written once rather than re-derived per
* provider.
*/
public final class ProviderResults {
private ProviderResults() {}
/** Classify a transport failure. */
public static ProviderSubmissionResult fromTransport(
NotificationHttpTransportException failure, Duration elapsed) {
if (failure.requestBodyCommitted()) {
return ProviderSubmissionResult.ambiguous(
new ProviderFailure(
NotificationFailureCode.PROVIDER_RESPONSE_LOST,
FailureCategory.AMBIGUOUS_SUBMISSION,
false,
Optional.empty(),
Optional.of(failure.reasonCode())),
ProviderExecutionEvidence.responseLost(),
elapsed);
}
return ProviderSubmissionResult.notSubmitted(
new ProviderFailure(
NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE,
FailureCategory.TRANSIENT_PROVIDER,
true,
Optional.empty(),
Optional.of(failure.reasonCode())),
elapsed);
}
/** Classify an HTTP status that is not provider-specific. */
public static ProviderFailure fromStatus(int statusCode, Optional<Duration> retryAfter) {
if (statusCode == 429) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_THROTTLED,
FailureCategory.THROTTLED,
true,
retryAfter,
Optional.of(Integer.toString(statusCode)));
}
if (statusCode == 401) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_AUTHENTICATION_FAILED,
FailureCategory.AUTHENTICATION,
false,
Optional.empty(),
Optional.of("401"));
}
if (statusCode == 403) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_AUTHORIZATION_FAILED,
FailureCategory.AUTHORIZATION,
false,
Optional.empty(),
Optional.of("403"));
}
if (statusCode >= 500) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE,
FailureCategory.TRANSIENT_PROVIDER,
true,
retryAfter,
Optional.of(Integer.toString(statusCode)));
}
return new ProviderFailure(
NotificationFailureCode.PROVIDER_PERMANENT_FAILURE,
FailureCategory.PERMANENT_PROVIDER,
false,
Optional.empty(),
Optional.of(Integer.toString(statusCode)));
}
/** Parse a {@code Retry-After} header expressed in seconds. */
public static Optional<Duration> retryAfter(Optional<String> headerValue) {
return headerValue.flatMap(
value -> {
try {
return Optional.of(Duration.ofSeconds(Long.parseLong(value.trim())));
} catch (NumberFormatException notSeconds) {
return Optional.empty();
}
});
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider;
import dev.caskeleton.application.notification.platform.api.content.AttachmentRef;
import dev.caskeleton.application.notification.platform.api.error.AttachmentUnavailableException;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.provider.AttachmentAccessContext;
import dev.caskeleton.application.notification.platform.provider.AttachmentResolver;
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
/**
* The resolver used when no attachment source is wired.
*
* <p>It refuses rather than returning an empty stream. Sending a mail whose attachment is silently
* missing is worse than not sending it: the recipient is told something is attached and it is not.
*
* <p>The composition root replaces this with a file-server or object-storage backed resolver; both
* leaves are visible there, and neither is reachable from this one.
*/
public final class UnconfiguredAttachmentResolver implements AttachmentResolver {
@Override
public ResolvedAttachment resolve(AttachmentRef reference, AttachmentAccessContext context) {
throw new AttachmentUnavailableException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.ATTACHMENT_UNAVAILABLE, FailureCategory.INVALID_PAYLOAD));
}
}
@@ -0,0 +1,67 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.apns;
import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.provider.ProviderFailure;
import java.util.Optional;
import java.util.Set;
/** Maps APNs reason strings onto the stable failure vocabulary. */
public final class ApnsFailureClassifier {
private static final Set<String> INVALID_TOKEN_REASONS =
Set.of("BadDeviceToken", "Unregistered", "DeviceTokenNotForTopic");
private static final Set<String> CONFIGURATION_REASONS =
Set.of("BadTopic", "TopicDisallowed", "BadCertificateEnvironment", "InvalidPushType");
/** Classify a non-2xx APNs response. */
public ProviderFailure classify(NotificationHttpResponse response) {
Optional<String> reason = reason(response);
if (reason.filter(INVALID_TOKEN_REASONS::contains).isPresent()) {
return new ProviderFailure(
NotificationFailureCode.CONTACT_POINT_INVALID,
FailureCategory.INVALID_RECIPIENT,
false,
Optional.empty(),
reason);
}
if (reason.filter(CONFIGURATION_REASONS::contains).isPresent()) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID,
FailureCategory.AUTHORIZATION,
false,
Optional.empty(),
reason);
}
if (reason.filter("ExpiredProviderToken"::equals).isPresent()) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_AUTHENTICATION_FAILED,
FailureCategory.AUTHENTICATION,
false,
Optional.empty(),
reason);
}
if (reason.filter("TooManyRequests"::equals).isPresent()) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_THROTTLED,
FailureCategory.THROTTLED,
true,
Optional.empty(),
reason);
}
return ProviderResults.fromStatus(response.statusCode(), Optional.empty());
}
private static Optional<String> reason(NotificationHttpResponse response) {
try {
var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString());
var reason = node.get("reason");
return reason == null || reason.isNull() ? Optional.empty() : Optional.of(reason.asString());
} catch (RuntimeException unparseable) {
return Optional.empty();
}
}
}
@@ -0,0 +1,100 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.apns;
import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import dev.caskeleton.application.notification.platform.security.AccessContext;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import java.time.Duration;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.function.Supplier;
/**
* APNs adapter.
*
* <p>A 2xx is acceptance. Apple documents that an accepted notification may be delivered, stored or
* discarded, and that ordering is not guaranteed, so this adapter never produces a delivery outcome
* and the platform never uses APNs as an ordered event transport.
*/
public final class ApnsNotificationProviderAdapter implements NotificationProviderAdapter {
private static final ProviderId PROVIDER_ID = new ProviderId("apns");
private final NotificationHttpGateway gateway;
private final ApnsRequestMapper mapper;
private final ApnsFailureClassifier classifier;
private final ContactPointProtector protector;
private final Supplier<String> authorizationSupplier;
public ApnsNotificationProviderAdapter(
NotificationHttpGateway gateway,
ApnsRequestMapper mapper,
ApnsFailureClassifier classifier,
ContactPointProtector protector,
Supplier<String> authorizationSupplier,
ApnsProviderProperties properties) {
// The profile is required at construction so a missing topic or environment fails at wiring
// time, but it is never exposed: a public accessor would leak an adapter type across the port.
Objects.requireNonNull(properties, "properties");
this.gateway = Objects.requireNonNull(gateway, "gateway");
this.mapper = Objects.requireNonNull(mapper, "mapper");
this.classifier = Objects.requireNonNull(classifier, "classifier");
this.protector = Objects.requireNonNull(protector, "protector");
this.authorizationSupplier =
Objects.requireNonNull(authorizationSupplier, "authorizationSupplier");
}
@Override
public ProviderId providerId() {
return PROVIDER_ID;
}
@Override
public Set<Channel> channels() {
return Set.of(Channel.PUSH);
}
@Override
public ProviderCapabilities capabilities() {
return new ProviderCapabilities(
false, false, false, false, false, false, false, true, 1, 4096L, Duration.ofDays(30));
}
@Override
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
Objects.requireNonNull(submission, "submission");
return CompletableFuture.completedFuture(send(submission));
}
private ProviderSubmissionResult send(ProviderSubmission submission) {
long startedNanos = System.nanoTime();
var contactPoint =
protector.reveal(
submission.contactPoint(),
AccessContext.dispatch(submission.profile().profileId().value()));
var request = mapper.map(submission, contactPoint, authorizationSupplier.get());
try {
NotificationHttpResponse response = gateway.exchange(request);
Duration elapsed = Duration.ofNanos(System.nanoTime() - startedNanos);
if (response.isSuccessful()) {
return ProviderSubmissionResult.accepted(
response.header("apns-id").orElse(null), "Accepted", elapsed);
}
return ProviderSubmissionResult.rejected(classifier.classify(response), elapsed);
} catch (NotificationHttpTransportException transportFailure) {
return ProviderResults.fromTransport(
transportFailure, Duration.ofNanos(System.nanoTime() - startedNanos));
}
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.apns;
import dev.caskeleton.application.notification.platform.contact.ApnsEnvironment;
import java.net.URI;
import java.time.Duration;
import java.util.Objects;
import java.util.Set;
/**
* APNs profile.
*
* <p>Environment and topic are required. A sandbox token sent to the production host is a silent
* non-delivery, so the pairing is checked before the call rather than diagnosed afterwards.
*/
public record ApnsProviderProperties(
URI endpoint,
String topic,
ApnsEnvironment environment,
Set<String> allowedPushTypes,
Duration timeout) {
public ApnsProviderProperties {
Objects.requireNonNull(endpoint, "endpoint");
Objects.requireNonNull(topic, "topic");
Objects.requireNonNull(environment, "environment");
allowedPushTypes = Set.copyOf(Objects.requireNonNull(allowedPushTypes, "allowedPushTypes"));
Objects.requireNonNull(timeout, "timeout");
if (topic.isBlank()) {
throw new IllegalArgumentException("topic");
}
if (allowedPushTypes.isEmpty()) {
throw new IllegalArgumentException("allowedPushTypes must not be empty");
}
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("timeout must be positive and finite");
}
}
}
@@ -0,0 +1,96 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.apns;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.content.MobilePushContent;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.api.error.ProviderConfigurationException;
import dev.caskeleton.application.notification.platform.contact.ApnsDeviceToken;
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/** Builds the APNs HTTP/2 request headers and payload. */
public final class ApnsRequestMapper {
private static final String DEFAULT_PUSH_TYPE = "alert";
private final ApnsProviderProperties properties;
private final Clock clock;
public ApnsRequestMapper(ApnsProviderProperties properties, Clock clock) {
this.properties = Objects.requireNonNull(properties, "properties");
this.clock = Objects.requireNonNull(clock, "clock");
}
/** Map one submission, rejecting an environment or push-type mismatch first. */
public NotificationHttpRequest map(
ProviderSubmission submission, ContactPointValue contactPoint, String authorization) {
Objects.requireNonNull(submission, "submission");
Objects.requireNonNull(authorization, "authorization");
if (!(contactPoint instanceof ApnsDeviceToken token)) {
throw new IllegalArgumentException("APNs requires an APNs device token");
}
if (token.environment() != properties.environment()) {
throw configurationFailure();
}
if (!(submission.content().content() instanceof MobilePushContent push)) {
throw new IllegalArgumentException("APNs requires mobile push content");
}
String pushType = DEFAULT_PUSH_TYPE;
if (!properties.allowedPushTypes().contains(pushType)) {
throw configurationFailure();
}
Map<String, Object> aps = new LinkedHashMap<>();
aps.put("alert", Map.of("title", push.title(), "body", push.body()));
push.presentation().sound().ifPresent(sound -> aps.put("sound", sound));
push.presentation().badge().ifPresent(badge -> aps.put("badge", badge));
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("aps", aps);
payload.putAll(push.data());
Map<String, String> headers = new LinkedHashMap<>();
headers.put("authorization", authorization);
headers.put("apns-topic", properties.topic());
headers.put("apns-push-type", pushType);
headers.put("apns-priority", "10");
headers.put("apns-id", submission.attemptId().value().toString());
submission
.expiresAt()
.ifPresent(
expiry -> headers.put("apns-expiration", Long.toString(expiry.getEpochSecond())));
submission.collapse().ifPresent(spec -> headers.put("apns-collapse-id", spec.key()));
byte[] body =
NotificationJsonMapper.mapper()
.writeValueAsString(payload)
.getBytes(StandardCharsets.UTF_8);
return new NotificationHttpRequest(
"POST",
URI.create(properties.endpoint() + "/3/device/" + token.value()),
JdkNotificationHttpGateway.headers(headers),
body,
properties.timeout());
}
/** Current time, exposed so expiry mapping stays testable. */
public java.time.Instant now() {
return clock.instant();
}
private static ProviderConfigurationException configurationFailure() {
return new ProviderConfigurationException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID, FailureCategory.AUTHORIZATION));
}
}
@@ -0,0 +1,89 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import dev.caskeleton.application.notification.platform.security.AccessContext;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
/**
* Batch submission that keeps per-recipient identity.
*
* <p>One transport call, many attempts. FCM returns a positional result per input, so a partial
* failure is decomposed back to the recipient that owns it; collapsing a batch into one shared
* outcome would mark four delivered recipients as failed because the fifth token was stale.
*/
public final class FcmBatchCoordinator {
private final FcmGateway gateway;
private final FcmMessageMapper messageMapper;
private final FcmTargetMapper targetMapper;
private final FcmFailureClassifier classifier;
private final ContactPointProtector protector;
private final FcmProviderProperties properties;
public FcmBatchCoordinator(
FcmGateway gateway,
FcmMessageMapper messageMapper,
FcmTargetMapper targetMapper,
FcmFailureClassifier classifier,
ContactPointProtector protector,
FcmProviderProperties properties) {
this.gateway = Objects.requireNonNull(gateway, "gateway");
this.messageMapper = Objects.requireNonNull(messageMapper, "messageMapper");
this.targetMapper = Objects.requireNonNull(targetMapper, "targetMapper");
this.classifier = Objects.requireNonNull(classifier, "classifier");
this.protector = Objects.requireNonNull(protector, "protector");
this.properties = Objects.requireNonNull(properties, "properties");
}
/** Submit a batch and return one result per input, in input order. */
public CompletionStage<List<ProviderSubmissionResult>> submit(
List<ProviderSubmission> submissions) {
Objects.requireNonNull(submissions, "submissions");
if (submissions.isEmpty()) {
return CompletableFuture.completedFuture(List.of());
}
if (submissions.size() > properties.maxBatchSize()) {
throw new ProviderPayloadLimitException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, FailureCategory.INVALID_PAYLOAD));
}
long startedNanos = System.nanoTime();
List<Map<String, Object>> messages = new ArrayList<>(submissions.size());
for (ProviderSubmission submission : submissions) {
ContactPointValue value =
protector.reveal(
submission.contactPoint(),
AccessContext.dispatch(submission.profile().profileId().value()));
messages.add(messageMapper.map(submission, targetMapper.map(value)));
}
FcmBatchResult batch = gateway.sendBatch(messages);
if (batch.items().size() != submissions.size()) {
throw new IllegalStateException("FCM returned a result count that does not match the input");
}
Duration elapsed = Duration.ofNanos(System.nanoTime() - startedNanos);
List<ProviderSubmissionResult> results = new ArrayList<>(submissions.size());
for (FcmBatchResult.Item item : batch.items()) {
results.add(
item.success()
? ProviderSubmissionResult.accepted(item.messageId().orElse(null), "SUCCESS", elapsed)
: classifier.classify(item.errorCode().orElseThrow(), elapsed));
}
return CompletableFuture.completedFuture(List.copyOf(results));
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/** Positional result of one FCM multicast call. */
public record FcmBatchResult(List<Item> items) {
public FcmBatchResult {
items = List.copyOf(Objects.requireNonNull(items, "items"));
}
/** One item result, aligned with the input index. */
public record Item(boolean success, Optional<String> messageId, Optional<String> errorCode) {
public Item {
Objects.requireNonNull(messageId, "messageId");
Objects.requireNonNull(errorCode, "errorCode");
if (success == errorCode.isPresent()) {
throw new IllegalArgumentException("an item is either a success or an error, never both");
}
}
/** Successful item. */
public static Item success(String messageId) {
return new Item(true, Optional.ofNullable(messageId), Optional.empty());
}
/** Failed item. */
public static Item failure(String errorCode) {
return new Item(false, Optional.empty(), Optional.of(errorCode));
}
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import dev.caskeleton.application.notification.platform.api.ContactPointId;
import dev.caskeleton.application.notification.platform.api.TenantId;
import dev.caskeleton.application.notification.platform.contact.ContactPointStatus;
import dev.caskeleton.application.notification.platform.dispatch.ContactPointStorePort;
import java.util.Objects;
/**
* Applies FCM target lifecycle changes.
*
* <p>An {@code UNREGISTERED} response is the provider telling us the target no longer exists. Not
* acting on it means every future notification to that user spends a provider call to learn the
* same thing again.
*/
public final class FcmContactPointUpdater {
private final ContactPointStorePort contactPoints;
private final FcmFailureClassifier classifier;
public FcmContactPointUpdater(
ContactPointStorePort contactPoints, FcmFailureClassifier classifier) {
this.contactPoints = Objects.requireNonNull(contactPoints, "contactPoints");
this.classifier = Objects.requireNonNull(classifier, "classifier");
}
/** Invalidate the contact point when the error code says the target is gone. */
public boolean apply(TenantId tenantId, ContactPointId contactPointId, String errorCode) {
Objects.requireNonNull(tenantId, "tenantId");
Objects.requireNonNull(contactPointId, "contactPointId");
Objects.requireNonNull(errorCode, "errorCode");
if (!classifier.invalidatesContactPoint(errorCode)) {
return false;
}
contactPoints.updateStatus(
tenantId, contactPointId, ContactPointStatus.INVALID, "FCM_" + errorCode);
return true;
}
}
@@ -0,0 +1,76 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.provider.ProviderFailure;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import java.time.Duration;
import java.util.Optional;
/**
* FCM error codes to the stable failure vocabulary.
*
* <p>{@code UNREGISTERED} is the one that must never be retried: the target is gone, and repeating
* the call cannot bring it back. It invalidates the contact point and lets routing fall back.
*/
public final class FcmFailureClassifier {
/** Classify one FCM error code. */
public ProviderSubmissionResult classify(String errorCode, Duration elapsed) {
return ProviderSubmissionResult.rejected(failure(errorCode), elapsed);
}
/** Failure for one FCM error code. */
public ProviderFailure failure(String errorCode) {
return switch (errorCode) {
case "UNREGISTERED", "INVALID_TOKEN" ->
ProviderFailure.of(
NotificationFailureCode.CONTACT_POINT_INVALID,
FailureCategory.INVALID_RECIPIENT,
false);
case "QUOTA_EXCEEDED" ->
ProviderFailure.of(
NotificationFailureCode.PROVIDER_THROTTLED, FailureCategory.THROTTLED, true);
case "UNAVAILABLE", "INTERNAL" ->
ProviderFailure.of(
NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE,
FailureCategory.TRANSIENT_PROVIDER,
true);
case "INVALID_ARGUMENT" ->
ProviderFailure.of(
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD, false);
case "THIRD_PARTY_AUTH_ERROR", "UNAUTHENTICATED" ->
ProviderFailure.of(
NotificationFailureCode.PROVIDER_AUTHENTICATION_FAILED,
FailureCategory.AUTHENTICATION,
false);
case "SENDER_ID_MISMATCH" ->
ProviderFailure.of(
NotificationFailureCode.PROVIDER_AUTHORIZATION_FAILED,
FailureCategory.AUTHORIZATION,
false);
default ->
ProviderFailure.of(
NotificationFailureCode.PROVIDER_PERMANENT_FAILURE,
FailureCategory.PERMANENT_PROVIDER,
false);
};
}
/** Whether an error code means the contact point should be invalidated. */
public boolean invalidatesContactPoint(String errorCode) {
return failure(errorCode).category() == FailureCategory.INVALID_RECIPIENT;
}
/** Retry hint, where FCM supplies one. */
public Optional<Duration> retryAfter(Optional<String> headerValue) {
return headerValue.flatMap(
value -> {
try {
return Optional.of(Duration.ofSeconds(Long.parseLong(value.trim())));
} catch (NumberFormatException notSeconds) {
return Optional.empty();
}
});
}
}
@@ -0,0 +1,12 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import java.util.List;
import java.util.Map;
/** The FCM transport seam, so batch decomposition can be tested without a live project. */
@FunctionalInterface
public interface FcmGateway {
/** Send a batch and return one positional result per message. */
FcmBatchResult sendBatch(List<Map<String, Object>> messages);
}
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import dev.caskeleton.application.notification.platform.api.content.MobilePushContent;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import java.time.Clock;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Builds the FCM message body.
*
* <p>TTL is the minimum of the remaining delivery deadline and the provider maximum. Sending the
* provider maximum when the notification expires in ninety seconds would let FCM keep retrying a
* message the platform has already given up on.
*/
public final class FcmMessageMapper {
private static final int MAX_PAYLOAD_BYTES = 4096;
private final FcmProviderProperties properties;
private final Clock clock;
public FcmMessageMapper(FcmProviderProperties properties, Clock clock) {
this.properties = Objects.requireNonNull(properties, "properties");
this.clock = Objects.requireNonNull(clock, "clock");
}
/** Message body for one submission. */
public Map<String, Object> map(ProviderSubmission submission, FcmWireTarget target) {
Objects.requireNonNull(submission, "submission");
Objects.requireNonNull(target, "target");
if (!(submission.content().content() instanceof MobilePushContent push)) {
throw new IllegalArgumentException("FCM requires mobile push content");
}
Map<String, Object> message = new LinkedHashMap<>();
if ("FID".equals(target.kind())) {
message.put("installation_id", target.value());
} else {
message.put("token", target.value());
}
message.put("notification", Map.of("title", push.title(), "body", push.body()));
if (!push.data().isEmpty()) {
message.put("data", push.data());
}
Map<String, Object> android = new LinkedHashMap<>();
android.put("ttl", ttl(submission).toSeconds() + "s");
submission.collapse().ifPresent(spec -> android.put("collapse_key", spec.key()));
message.put("android", android);
return Map.of("message", message);
}
/** Effective TTL for a submission. */
public Duration ttl(ProviderSubmission submission) {
Optional<Duration> remaining =
submission.expiresAt().map(expiry -> Duration.between(clock.instant(), expiry));
return remaining
.filter(value -> value.compareTo(properties.maxTtl()) < 0)
.filter(value -> !value.isNegative())
.orElse(properties.maxTtl());
}
/** Payload ceiling enforced before the provider call. */
public int maxPayloadBytes() {
return MAX_PAYLOAD_BYTES;
}
}
@@ -0,0 +1,73 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.provider.BatchNotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletionStage;
/**
* FCM adapter.
*
* <p>A successful send means FCM took the message. Firebase describes its own failures as handoff
* failures, which is the clearest statement that success is a handoff and not a device delivery, so
* the strongest evidence this adapter ever produces is {@code PROVIDER_ACCEPTED}.
*/
public final class FcmNotificationProviderAdapter implements BatchNotificationProviderAdapter {
private static final ProviderId PROVIDER_ID = new ProviderId("fcm");
private final FcmBatchCoordinator coordinator;
private final FcmProviderProperties properties;
public FcmNotificationProviderAdapter(
FcmBatchCoordinator coordinator, FcmProviderProperties properties) {
this.coordinator = Objects.requireNonNull(coordinator, "coordinator");
this.properties = Objects.requireNonNull(properties, "properties");
}
@Override
public ProviderId providerId() {
return PROVIDER_ID;
}
@Override
public Set<Channel> channels() {
return Set.of(Channel.PUSH);
}
@Override
public ProviderCapabilities capabilities() {
// deliveryReceipt is false: FCM has no server-side delivery receipt for ordinary sends, and
// claiming one would let the runtime plan a reconciliation that can never succeed.
return new ProviderCapabilities(
true,
false,
false,
false,
false,
false,
false,
true,
properties.maxBatchSize(),
4096L,
properties.maxTtl());
}
@Override
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
Objects.requireNonNull(submission, "submission");
return coordinator.submit(List.of(submission)).thenApply(results -> results.get(0));
}
@Override
public CompletionStage<List<ProviderSubmissionResult>> submitBatch(
List<ProviderSubmission> submissions) {
return coordinator.submit(submissions);
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import java.net.URI;
import java.time.Duration;
import java.util.Objects;
/** FCM profile. Project and application identity are pinned so a target cannot cross projects. */
public record FcmProviderProperties(
URI endpoint,
String projectId,
String applicationId,
int maxBatchSize,
Duration maxTtl,
Duration timeout) {
/** The Admin SDK multicast ceiling. */
public static final int MAX_SUPPORTED_BATCH = 500;
public FcmProviderProperties {
Objects.requireNonNull(endpoint, "endpoint");
Objects.requireNonNull(projectId, "projectId");
Objects.requireNonNull(applicationId, "applicationId");
Objects.requireNonNull(maxTtl, "maxTtl");
Objects.requireNonNull(timeout, "timeout");
if (projectId.isBlank() || applicationId.isBlank()) {
throw new IllegalArgumentException("projectId and applicationId must not be blank");
}
if (maxBatchSize < 1 || maxBatchSize > MAX_SUPPORTED_BATCH) {
throw new IllegalArgumentException("maxBatchSize must be 1.." + MAX_SUPPORTED_BATCH);
}
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("timeout must be positive and finite");
}
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
import dev.caskeleton.application.notification.platform.contact.FcmInstallationId;
import dev.caskeleton.application.notification.platform.contact.LegacyFcmRegistrationToken;
/** Maps typed push targets to their FCM wire representation. */
public final class FcmTargetMapper {
/** Wire target for a contact point value. */
public FcmWireTarget map(ContactPointValue value) {
return switch (value) {
case FcmInstallationId fid -> new FcmWireTarget("FID", fid.value());
case LegacyFcmRegistrationToken token -> new FcmWireTarget("LEGACY_TOKEN", token.value());
default -> throw new IllegalArgumentException("FCM requires an FCM target");
};
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import java.util.Objects;
/**
* A target in its wire form, with the kind kept explicit.
*
* <p>The kind is not cosmetic: an installation id and a legacy registration token go to different
* request fields, and flattening them would make a migration a runtime guess.
*/
public record FcmWireTarget(String kind, String value) {
public FcmWireTarget {
Objects.requireNonNull(kind, "kind");
Objects.requireNonNull(value, "value");
if (value.isBlank()) {
throw new IllegalArgumentException("value");
}
}
}
@@ -0,0 +1,103 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.http;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* Default gateway on the JDK HTTP client.
*
* <p>Redirects are never followed. A provider redirect would move a signed, credential-bearing
* request to a host the profile never approved.
*
* <p>Timeout and connection-reset failures are translated into an explicit statement about whether
* the body was committed, because that single bit is what separates a safe retry from a duplicate.
*/
public final class JdkNotificationHttpGateway implements NotificationHttpGateway {
// Restricted headers the JDK client refuses to let a caller set.
private static final Set<String> RESTRICTED =
Set.of("connection", "content-length", "expect", "host", "upgrade");
private final HttpClient client;
public JdkNotificationHttpGateway(Duration connectTimeout) {
this(
HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NEVER)
.connectTimeout(Objects.requireNonNull(connectTimeout, "connectTimeout"))
.build());
}
public JdkNotificationHttpGateway(HttpClient client) {
this.client = Objects.requireNonNull(client, "client");
}
@Override
public NotificationHttpResponse exchange(NotificationHttpRequest request) {
Objects.requireNonNull(request, "request");
HttpRequest.Builder builder =
HttpRequest.newBuilder(request.uri())
.timeout(request.timeout())
.method(request.method(), HttpRequest.BodyPublishers.ofByteArray(request.body()));
request
.headers()
.forEach(
(name, values) -> {
if (!RESTRICTED.contains(name)) {
values.forEach(value -> builder.header(name, value));
}
});
try {
HttpResponse<byte[]> response =
client.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());
return new NotificationHttpResponse(
response.statusCode(), Map.copyOf(response.headers().map()), response.body());
} catch (HttpTimeoutException timeout) {
// The request timed out after the body was published, so the provider may well have it.
throw new NotificationHttpTransportException("RESPONSE_TIMEOUT", true, timeout);
} catch (IOException failure) {
throw new NotificationHttpTransportException(
"TRANSPORT_FAILURE", bodyWasLikelyCommitted(failure), failure);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new NotificationHttpTransportException("INTERRUPTED", true, interrupted);
}
}
/**
* A connect failure happens before anything is written; anything else may have written the body.
*
* <p>The default is deliberately the pessimistic one: guessing "not committed" would turn an
* unknown into an automatic resend.
*/
private static boolean bodyWasLikelyCommitted(IOException failure) {
String message = failure.getMessage();
if (message == null) {
return true;
}
String normalized = message.toLowerCase(java.util.Locale.ROOT);
boolean beforeSend =
normalized.contains("connection refused")
|| normalized.contains("unresolved")
|| normalized.contains("no route to host")
|| normalized.contains("connect timed out");
return !beforeSend;
}
/** Header map helper for adapters. */
public static Map<String, List<String>> headers(Map<String, String> singleValued) {
return singleValued.entrySet().stream()
.collect(
java.util.stream.Collectors.toUnmodifiableMap(
Map.Entry::getKey, entry -> List.of(entry.getValue())));
}
}
@@ -0,0 +1,43 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.http;
import java.net.URI;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/** Endpoint validation shared by the provider profiles. */
public final class NotificationEndpoints {
private static final Set<String> LOOPBACK_HOSTS = Set.of("127.0.0.1", "::1", "localhost");
private NotificationEndpoints() {}
/**
* Require TLS, except on the loopback interface.
*
* <p>The exception is narrow on purpose. A plaintext provider endpoint on a routable host exposes
* credentials and message bodies to anything on the path, which is why it is refused outright. A
* loopback endpoint never leaves the machine, so the same reasoning does not apply — and without
* this the contract suite could not exercise a real socket at all, which would mean the ambiguity
* behaviour it exists to prove went untested.
*/
public static URI requireSecureOrLoopback(URI endpoint, String name) {
Objects.requireNonNull(endpoint, name);
String scheme =
endpoint.getScheme() == null ? "" : endpoint.getScheme().toLowerCase(Locale.ROOT);
if ("https".equals(scheme)) {
return endpoint;
}
String host = endpoint.getHost() == null ? "" : endpoint.getHost().toLowerCase(Locale.ROOT);
if ("http".equals(scheme) && LOOPBACK_HOSTS.contains(host)) {
return endpoint;
}
throw new IllegalArgumentException(name + " must use https outside the loopback interface");
}
/** Whether an endpoint is on the loopback interface. */
public static boolean isLoopback(URI endpoint) {
String host = endpoint.getHost() == null ? "" : endpoint.getHost().toLowerCase(Locale.ROOT);
return LOOPBACK_HOSTS.contains(host);
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.http;
/**
* The only way a provider adapter in this leaf reaches the network.
*
* <p>It exists as a port because the registry does not permit {@code adapter-outbound-notification
* → adapter-outbound-httpclient}. The composition root sees both leaves and is the supported place
* to substitute an implementation backed by the HTTP Client Platform, which brings its own TLS,
* circuit breaker, SSRF and dynamic-target policy.
*/
public interface NotificationHttpGateway {
/**
* Execute one request.
*
* @throws NotificationHttpTransportException when no response could be read; the exception states
* whether the request body was already committed
*/
NotificationHttpResponse exchange(NotificationHttpRequest request);
}
@@ -0,0 +1,64 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.http;
import java.net.URI;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
/** One outbound provider HTTP request. */
@SuppressWarnings("ArrayRecordComponent") // defensive copies on construction and on every accessor
public record NotificationHttpRequest(
String method, URI uri, Map<String, List<String>> headers, byte[] body, Duration timeout) {
public NotificationHttpRequest {
Objects.requireNonNull(method, "method");
Objects.requireNonNull(uri, "uri");
Objects.requireNonNull(headers, "headers");
Objects.requireNonNull(body, "body");
Objects.requireNonNull(timeout, "timeout");
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("timeout must be finite and positive");
}
headers =
headers.entrySet().stream()
.collect(
java.util.stream.Collectors.toUnmodifiableMap(
entry -> entry.getKey().toLowerCase(Locale.ROOT),
entry -> List.copyOf(entry.getValue())));
body = body.clone();
}
@Override
public byte[] body() {
return body.clone();
}
@Override
public boolean equals(Object other) {
return other instanceof NotificationHttpRequest request
&& method.equals(request.method)
&& uri.equals(request.uri)
&& headers.equals(request.headers)
&& Arrays.equals(body, request.body)
&& timeout.equals(request.timeout);
}
@Override
public int hashCode() {
return Objects.hash(method, uri, headers, Arrays.hashCode(body), timeout);
}
@Override
public String toString() {
// The URI is redacted because a Web Push endpoint is a capability URL and the request body may
// be a rendered message.
return "NotificationHttpRequest[method="
+ method
+ ", uri=redacted, bytes="
+ body.length
+ "]";
}
}
@@ -0,0 +1,66 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.http;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/** One provider HTTP response. */
@SuppressWarnings("ArrayRecordComponent") // defensive copies on construction and on every accessor
public record NotificationHttpResponse(
int statusCode, Map<String, List<String>> headers, byte[] body) {
public NotificationHttpResponse {
Objects.requireNonNull(headers, "headers");
Objects.requireNonNull(body, "body");
headers =
headers.entrySet().stream()
.collect(
java.util.stream.Collectors.toUnmodifiableMap(
entry -> entry.getKey().toLowerCase(Locale.ROOT),
entry -> List.copyOf(entry.getValue())));
body = body.clone();
}
@Override
public byte[] body() {
return body.clone();
}
/** Body decoded as UTF-8. */
public String bodyAsString() {
return new String(body, StandardCharsets.UTF_8);
}
/** First value of a header, matched case-insensitively. */
public Optional<String> header(String name) {
List<String> values = headers.get(name.toLowerCase(Locale.ROOT));
return values == null || values.isEmpty() ? Optional.empty() : Optional.of(values.get(0));
}
/** Whether the status is 2xx. */
public boolean isSuccessful() {
return statusCode >= 200 && statusCode < 300;
}
@Override
public boolean equals(Object other) {
return other instanceof NotificationHttpResponse response
&& statusCode == response.statusCode
&& headers.equals(response.headers)
&& Arrays.equals(body, response.body);
}
@Override
public int hashCode() {
return Objects.hash(statusCode, headers, Arrays.hashCode(body));
}
@Override
public String toString() {
return "NotificationHttpResponse[status=" + statusCode + ", bytes=" + body.length + "]";
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.http;
import java.util.Objects;
/**
* A provider HTTP call that produced no usable response.
*
* <p>{@code requestBodyCommitted} is the field that decides everything downstream: a connection
* that failed before the body was written is a safe retry, while one that failed after it was
* written is an ambiguous submission that must not be resent automatically.
*/
public class NotificationHttpTransportException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final boolean requestBodyCommitted;
private final String reasonCode;
public NotificationHttpTransportException(
String reasonCode, boolean requestBodyCommitted, Throwable cause) {
super(reasonCode, cause);
this.reasonCode = Objects.requireNonNull(reasonCode, "reasonCode");
this.requestBodyCommitted = requestBodyCommitted;
}
/** Whether the request body reached the provider before the failure. */
public boolean requestBodyCommitted() {
return requestBodyCommitted;
}
/** Bounded reason code, safe for logs and metrics. */
public String reasonCode() {
return reasonCode;
}
}
@@ -0,0 +1,147 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* AWS Signature Version 4.
*
* <p>Implemented here rather than pulled in with an SDK because the SDK would also bring its own
* HTTP client, retry policy and credential chain — three things this platform already owns and
* whose duplication would quietly move retry ownership out of the notification retry policy.
*/
public final class AwsSignatureV4Signer {
private static final String ALGORITHM = "AWS4-HMAC-SHA256";
private static final DateTimeFormatter AMZ_DATE =
DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC);
private static final DateTimeFormatter DATE_STAMP =
DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneOffset.UTC);
/** Signed headers to add to a request. */
public record SignedHeaders(String authorization, String amzDate, String contentSha256) {
public SignedHeaders {
Objects.requireNonNull(authorization, "authorization");
Objects.requireNonNull(amzDate, "amzDate");
Objects.requireNonNull(contentSha256, "contentSha256");
}
}
/** Sign one request. */
public SignedHeaders sign(
String method,
String canonicalUri,
String canonicalQuery,
Map<String, String> headers,
byte[] body,
String accessKeyId,
byte[] secretAccessKey,
String region,
String service,
Instant signedAt) {
Objects.requireNonNull(method, "method");
Objects.requireNonNull(canonicalUri, "canonicalUri");
Objects.requireNonNull(canonicalQuery, "canonicalQuery");
Objects.requireNonNull(headers, "headers");
Objects.requireNonNull(body, "body");
Objects.requireNonNull(signedAt, "signedAt");
String amzDate = AMZ_DATE.format(signedAt);
String dateStamp = DATE_STAMP.format(signedAt);
String payloadHash = hex(sha256(body));
TreeMap<String, String> canonicalHeaders = new TreeMap<>();
headers.forEach(
(name, value) -> canonicalHeaders.put(name.toLowerCase(Locale.ROOT), value.trim()));
canonicalHeaders.put("x-amz-date", amzDate);
canonicalHeaders.put("x-amz-content-sha256", payloadHash);
StringBuilder canonicalHeaderBlock = new StringBuilder();
canonicalHeaders.forEach(
(name, value) -> canonicalHeaderBlock.append(name).append(':').append(value).append('\n'));
String signedHeaderNames = String.join(";", canonicalHeaders.keySet());
String canonicalRequest =
method
+ '\n'
+ canonicalUri
+ '\n'
+ canonicalQuery
+ '\n'
+ canonicalHeaderBlock
+ '\n'
+ signedHeaderNames
+ '\n'
+ payloadHash;
String credentialScope = dateStamp + "/" + region + "/" + service + "/aws4_request";
String stringToSign =
ALGORITHM
+ '\n'
+ amzDate
+ '\n'
+ credentialScope
+ '\n'
+ hex(sha256(canonicalRequest.getBytes(StandardCharsets.UTF_8)));
byte[] signingKey = signingKey(secretAccessKey, dateStamp, region, service);
String signature = hex(hmac(signingKey, stringToSign.getBytes(StandardCharsets.UTF_8)));
String authorization =
ALGORITHM
+ " Credential="
+ accessKeyId
+ "/"
+ credentialScope
+ ", SignedHeaders="
+ signedHeaderNames
+ ", Signature="
+ signature;
return new SignedHeaders(authorization, amzDate, payloadHash);
}
private static byte[] signingKey(
byte[] secretAccessKey, String dateStamp, String region, String service) {
byte[] key =
("AWS4" + new String(secretAccessKey, StandardCharsets.UTF_8))
.getBytes(StandardCharsets.UTF_8);
byte[] dateKey = hmac(key, dateStamp.getBytes(StandardCharsets.UTF_8));
byte[] regionKey = hmac(dateKey, region.getBytes(StandardCharsets.UTF_8));
byte[] serviceKey = hmac(regionKey, service.getBytes(StandardCharsets.UTF_8));
return hmac(serviceKey, "aws4_request".getBytes(StandardCharsets.UTF_8));
}
private static byte[] hmac(byte[] key, byte[] data) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return mac.doFinal(data);
} catch (java.security.GeneralSecurityException failure) {
throw new IllegalStateException("HmacSHA256 is required by the Java platform", failure);
}
}
private static byte[] sha256(byte[] value) {
try {
return MessageDigest.getInstance("SHA-256").digest(value);
} catch (NoSuchAlgorithmException failure) {
throw new IllegalStateException("SHA-256 is required by the Java platform", failure);
}
}
private static String hex(byte[] value) {
return HexFormat.of().formatHex(value);
}
}
@@ -0,0 +1,82 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.callback.CallbackRequest;
import dev.caskeleton.application.notification.platform.callback.CallbackVerificationResult;
import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter;
import dev.caskeleton.application.notification.platform.callback.VerifiedCallback;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import tools.jackson.databind.JsonNode;
/**
* SES event ingestion over SNS.
*
* <p>A subscription confirmation is verified like any other message but produces no provider event:
* confirming a topic is an operational act, and letting it into the ledger would mean an unverified
* caller could add rows just by claiming to be SNS.
*/
public final class SesCallbackAdapter implements ProviderCallbackAdapter {
private static final ProviderId PROVIDER_ID = new ProviderId("ses");
private final SnsSignatureVerifier verifier;
private final SesEventNormalizer normalizer;
public SesCallbackAdapter(SnsSignatureVerifier verifier, SesEventNormalizer normalizer) {
this.verifier = Objects.requireNonNull(verifier, "verifier");
this.normalizer = Objects.requireNonNull(normalizer, "normalizer");
}
@Override
public ProviderId providerId() {
return PROVIDER_ID;
}
@Override
public CallbackVerificationResult verify(CallbackRequest request) {
Objects.requireNonNull(request, "request");
Map<String, String> envelope;
try {
envelope = flatten(new String(request.body(), StandardCharsets.UTF_8));
} catch (RuntimeException unparseable) {
return CallbackVerificationResult.invalid("SNS_ENVELOPE_UNPARSEABLE");
}
if (!verifier.isValid(envelope)) {
return CallbackVerificationResult.invalid("SNS_SIGNATURE_MISMATCH");
}
return CallbackVerificationResult.valid(new VerifiedCallback(request, envelope));
}
@Override
public List<NormalizedProviderEvent> normalize(VerifiedCallback callback) {
Objects.requireNonNull(callback, "callback");
Map<String, String> envelope = callback.canonicalParameters();
String type = envelope.getOrDefault("Type", "Notification");
if (!"Notification".equals(type)) {
// Confirmations and unsubscribes are handled by operations, not by the delivery ledger.
return List.of();
}
String message = envelope.getOrDefault("Message", "{}");
return normalizer.normalize(message);
}
private static Map<String, String> flatten(String body) {
JsonNode root = NotificationJsonMapper.mapper().readTree(body);
Map<String, String> envelope = new LinkedHashMap<>();
root.properties()
.forEach(
property -> {
JsonNode value = property.getValue();
if (value != null && value.isValueNode()) {
envelope.put(property.getKey(), value.asString());
}
});
return envelope;
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.callback.StandardDeliveryProjector;
/**
* SES projector.
*
* <p>SES adds no transition of its own: delivery, bounce and complaint all obey the shared table,
* and the interesting SES-specific behaviour — a complaint arriving after a delivery — is exactly
* what the shared table already gets right.
*/
public final class SesDeliveryProjector extends StandardDeliveryProjector {
public SesDeliveryProjector() {
super(new ProviderId("ses"));
}
}
@@ -0,0 +1,101 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.callback.NormalizedEventType;
import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import tools.jackson.databind.JsonNode;
/**
* SES event publishing to the stable vocabulary.
*
* <p>Bounce type decides the suppression consequence: a permanent bounce invalidates the address,
* while a transient one is a retry input. Collapsing both into one reason would remove an address
* because a mailbox was briefly full.
*/
public final class SesEventNormalizer {
/** Normalize one SES notification payload. */
public List<NormalizedProviderEvent> normalize(String payload) {
JsonNode root = NotificationJsonMapper.mapper().readTree(payload);
String type = text(root, "eventType").orElse(text(root, "notificationType").orElse("Unknown"));
Optional<String> messageId =
Optional.ofNullable(root.get("mail")).flatMap(mail -> text(mail, "messageId"));
Optional<Instant> occurredAt = timestamp(root, type);
List<NormalizedProviderEvent> events = new ArrayList<>(1);
events.add(
switch (type) {
case "Send" ->
event(NormalizedEventType.PROVIDER_ACCEPTED, type, messageId, occurredAt, Map.of());
case "Delivery" ->
event(NormalizedEventType.DELIVERY_CONFIRMED, type, messageId, occurredAt, Map.of());
case "DeliveryDelay" ->
event(NormalizedEventType.DELIVERY_DELAYED, type, messageId, occurredAt, Map.of());
case "Bounce" -> bounce(root, type, messageId, occurredAt);
case "Complaint" ->
event(NormalizedEventType.COMPLAINT, type, messageId, occurredAt, Map.of());
case "Reject" ->
event(NormalizedEventType.PROVIDER_REJECTED, type, messageId, occurredAt, Map.of());
case "RenderingFailure" ->
event(NormalizedEventType.TEMPLATE_FAILURE, type, messageId, occurredAt, Map.of());
case "Open" -> event(NormalizedEventType.OPENED, type, messageId, occurredAt, Map.of());
case "Click" -> event(NormalizedEventType.CLICKED, type, messageId, occurredAt, Map.of());
default -> event(NormalizedEventType.UNKNOWN, type, messageId, occurredAt, Map.of());
});
return List.copyOf(events);
}
private static NormalizedProviderEvent bounce(
JsonNode root, String type, Optional<String> messageId, Optional<Instant> occurredAt) {
String bounceType =
Optional.ofNullable(root.get("bounce"))
.flatMap(bounce -> text(bounce, "bounceType"))
.orElse("Undetermined");
NormalizedEventType normalized =
"Permanent".equals(bounceType)
? NormalizedEventType.BOUNCED_HARD
: NormalizedEventType.BOUNCED_SOFT;
return event(
normalized,
type + "/" + bounceType,
messageId,
occurredAt,
Map.of("bounceType", bounceType));
}
private static NormalizedProviderEvent event(
NormalizedEventType type,
String nativeType,
Optional<String> messageId,
Optional<Instant> occurredAt,
Map<String, String> attributes) {
return new NormalizedProviderEvent(
type, nativeType, Optional.empty(), messageId, occurredAt, attributes);
}
private static Optional<String> text(JsonNode node, String field) {
JsonNode value = node.get(field);
return value == null || value.isNull() ? Optional.empty() : Optional.of(value.asString());
}
private static Optional<Instant> timestamp(JsonNode root, String type) {
JsonNode section = root.get(type.toLowerCase(java.util.Locale.ROOT));
if (section == null) {
return Optional.empty();
}
return text(section, "timestamp")
.flatMap(
value -> {
try {
return Optional.of(Instant.parse(value));
} catch (java.time.format.DateTimeParseException unparseable) {
return Optional.empty();
}
});
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.provider.ProviderFailure;
import java.util.Optional;
/** Maps SES error responses onto the stable failure vocabulary. */
public final class SesFailureClassifier {
/** Classify a non-2xx SES response. */
public ProviderFailure classify(NotificationHttpResponse response) {
String body = response.bodyAsString();
if (body.contains("MessageRejected")) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_REJECTED,
FailureCategory.PERMANENT_PROVIDER,
false,
Optional.empty(),
Optional.of("MessageRejected"));
}
if (body.contains("MailFromDomainNotVerified") || body.contains("SendingPausedException")) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID,
FailureCategory.AUTHORIZATION,
false,
Optional.empty(),
Optional.of("SenderIdentityNotReady"));
}
if (body.contains("TooManyRequestsException") || response.statusCode() == 429) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_THROTTLED,
FailureCategory.THROTTLED,
true,
ProviderResults.retryAfter(response.header("retry-after")),
Optional.of("TooManyRequests"));
}
if (body.contains("AccountSuspendedException")) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_AUTHORIZATION_FAILED,
FailureCategory.AUTHORIZATION,
false,
Optional.empty(),
Optional.of("AccountSuspended"));
}
return ProviderResults.fromStatus(
response.statusCode(), ProviderResults.retryAfter(response.header("retry-after")));
}
}
@@ -0,0 +1,134 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import dev.caskeleton.application.notification.platform.security.AccessContext;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.time.Clock;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
/**
* Amazon SES submission.
*
* <p>{@code MessageId} is stored as the provider request id and mapped to {@code
* PROVIDER_ACCEPTED}. SES documents that it can accept a request and still decline to send — for a
* virus finding or a bad personalisation — so promoting acceptance to delivery would be wrong by
* the provider's own contract, not merely conservative.
*
* <p>Retry ownership stays with the notification retry policy: the gateway performs no blind retry,
* because a resend after a lost response is exactly the decision the evidence model must make.
*/
public final class SesNotificationProviderAdapter implements NotificationProviderAdapter {
private static final ProviderId PROVIDER_ID = new ProviderId("ses");
private final NotificationHttpGateway gateway;
private final SesRequestMapper mapper;
private final SesFailureClassifier classifier;
private final ContactPointProtector protector;
private final SecretMaterialProvider secrets;
private final String accessKeyId;
private final Clock clock;
public SesNotificationProviderAdapter(
NotificationHttpGateway gateway,
SesRequestMapper mapper,
SesFailureClassifier classifier,
ContactPointProtector protector,
SecretMaterialProvider secrets,
String accessKeyId,
Clock clock) {
this.gateway = Objects.requireNonNull(gateway, "gateway");
this.mapper = Objects.requireNonNull(mapper, "mapper");
this.classifier = Objects.requireNonNull(classifier, "classifier");
this.protector = Objects.requireNonNull(protector, "protector");
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.accessKeyId = Objects.requireNonNull(accessKeyId, "accessKeyId");
this.clock = Objects.requireNonNull(clock, "clock");
}
@Override
public ProviderId providerId() {
return PROVIDER_ID;
}
@Override
public Set<Channel> channels() {
return Set.of(Channel.EMAIL);
}
@Override
public ProviderCapabilities capabilities() {
return new ProviderCapabilities(
false, false, true, false, false, false, false, false, 1, 10_000_000L, Duration.ofDays(1));
}
@Override
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
Objects.requireNonNull(submission, "submission");
return CompletableFuture.completedFuture(send(submission));
}
private ProviderSubmissionResult send(ProviderSubmission submission) {
long startedNanos = System.nanoTime();
ContactPointValue value =
protector.reveal(
submission.contactPoint(),
AccessContext.dispatch(submission.profile().profileId().value()));
if (!(value instanceof EmailAddress address)) {
throw new IllegalArgumentException("SES requires an email contact point");
}
var request =
mapper.map(
submission,
address.normalized(),
accessKeyId,
secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material(),
clock.instant());
try {
NotificationHttpResponse response = gateway.exchange(request);
Duration elapsed = elapsedSince(startedNanos);
if (response.isSuccessful()) {
return ProviderSubmissionResult.accepted(messageId(response), "Accepted", elapsed);
}
return ProviderSubmissionResult.rejected(classifier.classify(response), elapsed);
} catch (NotificationHttpTransportException transportFailure) {
return ProviderResults.fromTransport(transportFailure, elapsedSince(startedNanos));
}
}
private static String messageId(NotificationHttpResponse response) {
try {
var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString());
return Optional.ofNullable(node.get("MessageId")).map(value -> value.asString()).orElse(null);
} catch (RuntimeException unparseable) {
// A 2xx without a parseable body is still acceptance; the platform simply has no provider
// request id to reconcile against later.
return null;
}
}
private static Duration elapsedSince(long startedNanos) {
return Duration.ofNanos(System.nanoTime() - startedNanos);
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationEndpoints;
import java.net.URI;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
/** SES profile. Configuration sets are approved N3 options, not free-form provider parameters. */
public record SesProviderProperties(
URI endpoint,
String region,
String senderIdentity,
Optional<String> configurationSet,
Duration timeout) {
public SesProviderProperties {
Objects.requireNonNull(endpoint, "endpoint");
Objects.requireNonNull(region, "region");
Objects.requireNonNull(senderIdentity, "senderIdentity");
Objects.requireNonNull(configurationSet, "configurationSet");
Objects.requireNonNull(timeout, "timeout");
NotificationEndpoints.requireSecureOrLoopback(endpoint, "SES endpoint");
if (region.isBlank() || senderIdentity.isBlank()) {
throw new IllegalArgumentException("region and senderIdentity must not be blank");
}
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("timeout must be positive and finite");
}
}
}
@@ -0,0 +1,86 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/** Builds the signed SES v2 send request. */
public final class SesRequestMapper {
private static final String PATH = "/v2/email/outbound-emails";
private final SesProviderProperties properties;
private final AwsSignatureV4Signer signer;
public SesRequestMapper(SesProviderProperties properties, AwsSignatureV4Signer signer) {
this.properties = Objects.requireNonNull(properties, "properties");
this.signer = Objects.requireNonNull(signer, "signer");
}
/** Map one submission into a signed request. */
public NotificationHttpRequest map(
ProviderSubmission submission,
String recipientAddress,
String accessKeyId,
byte[] secretAccessKey,
Instant signedAt) {
Objects.requireNonNull(submission, "submission");
if (!(submission.content().content() instanceof EmailContent email)) {
throw new IllegalArgumentException("SES requires email content");
}
Map<String, Object> simple = new LinkedHashMap<>();
simple.put("Subject", Map.of("Data", email.subject(), "Charset", "UTF-8"));
Map<String, Object> bodyParts = new LinkedHashMap<>();
bodyParts.put("Text", Map.of("Data", email.textBody(), "Charset", "UTF-8"));
email
.htmlBody()
.ifPresent(html -> bodyParts.put("Html", Map.of("Data", html, "Charset", "UTF-8")));
simple.put("Body", bodyParts);
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("FromEmailAddress", properties.senderIdentity());
payload.put("Destination", Map.of("ToAddresses", java.util.List.of(recipientAddress)));
payload.put("Content", Map.of("Simple", simple));
properties.configurationSet().ifPresent(name -> payload.put("ConfigurationSetName", name));
byte[] body =
NotificationJsonMapper.mapper()
.writeValueAsString(payload)
.getBytes(StandardCharsets.UTF_8);
String host = properties.endpoint().getHost();
var signed =
signer.sign(
"POST",
PATH,
"",
Map.of("host", host, "content-type", "application/json"),
body,
accessKeyId,
secretAccessKey,
properties.region(),
"ses",
signedAt);
return new NotificationHttpRequest(
"POST",
URI.create(properties.endpoint().toString() + PATH),
JdkNotificationHttpGateway.headers(
Map.of(
"content-type", "application/json",
"x-amz-date", signed.amzDate(),
"x-amz-content-sha256", signed.contentSha256(),
"authorization", signed.authorization())),
body,
properties.timeout());
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
import dev.caskeleton.application.notification.platform.callback.NormalizedEventType;
import dev.caskeleton.application.notification.platform.callback.NotificationSideEffectPort;
import dev.caskeleton.application.notification.platform.callback.SuppressionFacts;
import java.util.Objects;
/**
* Turns an SES event into the suppression fact it implies.
*
* <p>A permanent bounce and a transient one map to different facts on purpose: treating a full
* mailbox like a dead address removes a recipient who would have received the next message fine.
*/
public final class SesSuppressionUpdater {
private final NotificationSideEffectPort sideEffects;
public SesSuppressionUpdater(NotificationSideEffectPort sideEffects) {
this.sideEffects = Objects.requireNonNull(sideEffects, "sideEffects");
}
/** Apply the suppression consequence of one normalized event. */
public boolean apply(DeliveryAttemptSnapshot attempt, NormalizedEventType type) {
Objects.requireNonNull(attempt, "attempt");
Objects.requireNonNull(type, "type");
SuppressionFacts facts =
switch (type) {
case BOUNCED_HARD -> SuppressionFacts.NONE.withHardBounce().withInvalidTarget();
case COMPLAINT -> SuppressionFacts.NONE.withComplaint();
case INVALID_RECIPIENT -> SuppressionFacts.NONE.withInvalidTarget();
// A soft bounce is explicitly not a suppression: it is a retry input.
default -> SuppressionFacts.NONE;
};
if (!facts.requiresSuppression()) {
return false;
}
sideEffects.apply(attempt, facts);
return true;
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import java.net.URI;
import java.security.PublicKey;
/**
* Supplies the public key of an SNS signing certificate.
*
* <p>A port so the fetch, its cache and its TLS policy stay outside the verifier — and so a
* contract test can verify signatures without reaching the network.
*/
public interface SnsCertificateProvider {
/** Public key of the certificate at a URL that has already been host-checked. */
PublicKey publicKeyFor(URI certificateUrl);
}
@@ -0,0 +1,108 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.PublicKey;
import java.security.Signature;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* SNS message signature verification.
*
* <p>Two checks, and both are load-bearing. The signing certificate URL is constrained to an
* Amazon-owned host before it is fetched, because a message that names its own certificate host is
* otherwise self-signed by whoever sent it. The canonical string is then rebuilt from the fields
* SNS specifies, in its order, because signing a re-serialised body would verify our own JSON
* writer rather than the message.
*/
public final class SnsSignatureVerifier {
private static final Set<String> NOTIFICATION_FIELDS =
Set.of("Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type");
private static final Set<String> SUBSCRIPTION_FIELDS =
Set.of("Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type");
private final SnsCertificateProvider certificates;
private final String certificateHostSuffix;
public SnsSignatureVerifier(SnsCertificateProvider certificates, String certificateHostSuffix) {
this.certificates = Objects.requireNonNull(certificates, "certificates");
this.certificateHostSuffix =
Objects.requireNonNull(certificateHostSuffix, "certificateHostSuffix");
if (certificateHostSuffix.isBlank()) {
throw new IllegalArgumentException("certificateHostSuffix");
}
}
/** Verify one SNS envelope. */
public boolean isValid(Map<String, String> envelope) {
Objects.requireNonNull(envelope, "envelope");
String certificateUrl = envelope.get("SigningCertURL");
String signature = envelope.get("Signature");
String version = envelope.getOrDefault("SignatureVersion", "1");
if (certificateUrl == null || signature == null) {
return false;
}
if (!isTrustedCertificateUrl(certificateUrl)) {
return false;
}
try {
PublicKey key = certificates.publicKeyFor(URI.create(certificateUrl));
Signature verifier =
Signature.getInstance("2".equals(version) ? "SHA256withRSA" : "SHA1withRSA");
verifier.initVerify(key);
verifier.update(canonicalString(envelope).getBytes(StandardCharsets.UTF_8));
return verifier.verify(Base64.getDecoder().decode(signature));
} catch (GeneralSecurityException | IllegalArgumentException failure) {
return false;
}
}
/** Whether the certificate URL is on an Amazon host over TLS. */
public boolean isTrustedCertificateUrl(String certificateUrl) {
try {
URI uri = URI.create(certificateUrl);
String host = uri.getHost() == null ? "" : uri.getHost().toLowerCase(Locale.ROOT);
return "https".equalsIgnoreCase(uri.getScheme()) && host.endsWith(certificateHostSuffix);
} catch (IllegalArgumentException malformed) {
return false;
}
}
/** The exact field-name/value sequence SNS signs. */
public static String canonicalString(Map<String, String> envelope) {
Set<String> fields =
"SubscriptionConfirmation".equals(envelope.get("Type"))
|| "UnsubscribeConfirmation".equals(envelope.get("Type"))
? SUBSCRIPTION_FIELDS
: NOTIFICATION_FIELDS;
Map<String, String> ordered = new LinkedHashMap<>();
List.of(
"Message",
"MessageId",
"Subject",
"SubscribeURL",
"Timestamp",
"Token",
"TopicArn",
"Type")
.stream()
.filter(fields::contains)
.filter(envelope::containsKey)
.forEach(name -> ordered.put(name, envelope.get(name)));
StringBuilder canonical = new StringBuilder();
ordered.forEach(
(name, value) -> canonical.append(name).append('\n').append(value).append('\n'));
return canonical.toString();
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
import jakarta.mail.internet.MimeMessage;
/**
* The single SMTP send operation.
*
* <p>Extracted behind an interface so the adapter's classification rules can be exercised against
* every SMTP outcome — including a connection lost after {@code DATA} — without a live relay.
*/
@FunctionalInterface
public interface SmtpDispatch {
/**
* Send one message.
*
* @throws SmtpDispatchException with the reply code, or with the fact that the body was already
* committed when the connection dropped
*/
void send(MimeMessage message);
}
@@ -0,0 +1,30 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
import java.util.Objects;
import java.util.Optional;
/** An SMTP send that did not complete with a final acceptance. */
public class SmtpDispatchException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient Optional<Integer> replyCode;
private final boolean dataCommitted;
public SmtpDispatchException(
String reasonCode, Optional<Integer> replyCode, boolean dataCommitted, Throwable cause) {
super(reasonCode, cause);
this.replyCode = Objects.requireNonNull(replyCode, "replyCode");
this.dataCommitted = dataCommitted;
}
/** SMTP reply code, when the server answered at all. */
public Optional<Integer> replyCode() {
return replyCode;
}
/** Whether the message body had already been transmitted when the failure happened. */
public boolean dataCommitted() {
return dataCommitted;
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.provider.ProviderExecutionEvidence;
import dev.caskeleton.application.notification.platform.provider.ProviderFailure;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import java.time.Duration;
import java.util.Optional;
import java.util.Set;
/**
* RFC 5321 reply-code classification.
*
* <p>4yz is a temporary failure the client may repeat; 5yz is permanent and must not be repeated
* unchanged. The interesting case is neither: a connection lost after {@code DATA} means the relay
* may already hold the message, so {@code SMTP_SEND_FAILED = safe retry} is exactly the
* simplification this classifier exists to prevent.
*/
public final class SmtpFailureClassifier {
/** Reply codes that identify the recipient rather than the transaction as the problem. */
private static final Set<Integer> INVALID_RECIPIENT_CODES = Set.of(550, 551, 553, 511);
/** Classify a failed send. */
public ProviderSubmissionResult classify(SmtpDispatchException failure, Duration elapsed) {
Optional<Integer> replyCode = failure.replyCode();
if (replyCode.isEmpty()) {
if (failure.dataCommitted()) {
return ProviderSubmissionResult.ambiguous(
new ProviderFailure(
NotificationFailureCode.PROVIDER_RESPONSE_LOST,
FailureCategory.AMBIGUOUS_SUBMISSION,
false,
Optional.empty(),
Optional.of(failure.getMessage())),
ProviderExecutionEvidence.responseLost(),
elapsed);
}
return ProviderSubmissionResult.notSubmitted(
new ProviderFailure(
NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE,
FailureCategory.TRANSIENT_PROVIDER,
true,
Optional.empty(),
Optional.of(failure.getMessage())),
elapsed);
}
int code = replyCode.get();
if (code >= 400 && code < 500) {
return ProviderSubmissionResult.rejected(
new ProviderFailure(
NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE,
FailureCategory.TRANSIENT_PROVIDER,
true,
Optional.empty(),
Optional.of(Integer.toString(code))),
elapsed);
}
if (INVALID_RECIPIENT_CODES.contains(code)) {
return ProviderSubmissionResult.rejected(
new ProviderFailure(
NotificationFailureCode.CONTACT_POINT_INVALID,
FailureCategory.INVALID_RECIPIENT,
false,
Optional.empty(),
Optional.of(Integer.toString(code))),
elapsed);
}
return ProviderSubmissionResult.rejected(
new ProviderFailure(
NotificationFailureCode.PROVIDER_PERMANENT_FAILURE,
FailureCategory.PERMANENT_PROVIDER,
false,
Optional.empty(),
Optional.of(Integer.toString(code))),
elapsed);
}
}
@@ -0,0 +1,95 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.api.error.NotificationValidationException;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
import jakarta.mail.internet.MimeMessage;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
import org.springframework.mail.javamail.MimeMessageHelper;
/**
* Builds the MIME message.
*
* <p>Text and HTML are assembled as {@code multipart/alternative} and everything is UTF-8. Header
* values containing CR or LF are rejected before the message is built: header injection is the one
* email failure that turns a notification into someone else's mail.
*
* <p>A MIME construction failure is a non-retryable rejection, and it happens before any relay is
* contacted.
*/
public final class SmtpMimeMessageFactory {
private final Session session;
public SmtpMimeMessageFactory(Session session) {
this.session = Objects.requireNonNull(session, "session");
}
/** Build a message for one submission. */
public MimeMessage create(
ProviderSubmission submission,
String recipientAddress,
String fromAddress,
List<ResolvedAttachment> attachments) {
Objects.requireNonNull(submission, "submission");
Objects.requireNonNull(recipientAddress, "recipientAddress");
Objects.requireNonNull(fromAddress, "fromAddress");
Objects.requireNonNull(attachments, "attachments");
if (!(submission.content().content() instanceof EmailContent email)) {
throw rejection();
}
requireHeaderSafe(recipientAddress);
requireHeaderSafe(fromAddress);
requireHeaderSafe(email.subject());
try {
MimeMessage message = new MimeMessage(session);
MimeMessageHelper helper =
new MimeMessageHelper(
message,
!attachments.isEmpty() || email.htmlBody().isPresent(),
StandardCharsets.UTF_8.name());
helper.setFrom(fromAddress);
helper.setTo(recipientAddress);
helper.setSubject(email.subject());
if (email.htmlBody().isPresent()) {
helper.setText(email.textBody(), email.htmlBody().get());
} else {
helper.setText(email.textBody(), false);
}
for (ResolvedAttachment attachment : attachments) {
helper.addAttachment(
attachment.displayName(), () -> attachment.content(), attachment.contentType());
}
for (var header : email.options().approvedHeaders().entrySet()) {
requireHeaderSafe(header.getKey());
requireHeaderSafe(header.getValue());
message.setHeader(header.getKey(), header.getValue());
}
return message;
} catch (MessagingException failure) {
throw rejection();
}
}
private static void requireHeaderSafe(String value) {
if (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\0') >= 0) {
throw rejection();
}
}
private static NotificationValidationException rejection() {
return new NotificationValidationException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD));
}
}
@@ -0,0 +1,102 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import dev.caskeleton.application.notification.platform.security.AccessContext;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
/**
* SMTP email adapter.
*
* <p>A final {@code 250} is provider acceptance and nothing more. The relay has taken
* responsibility for the message; whether it reaches an inbox is a separate question this adapter
* cannot answer, so the result carries {@code PROVIDER_ACCEPTED} and a delivery outcome of {@code
* UNKNOWN}.
*/
public final class SmtpNotificationProviderAdapter implements NotificationProviderAdapter {
private static final ProviderId PROVIDER_ID = new ProviderId("smtp");
private final SmtpDispatch dispatch;
private final SmtpMimeMessageFactory mimeFactory;
private final SmtpFailureClassifier classifier;
private final ContactPointProtector protector;
private final SmtpProviderProperties properties;
private final Executor executor;
public SmtpNotificationProviderAdapter(
SmtpDispatch dispatch,
SmtpMimeMessageFactory mimeFactory,
SmtpFailureClassifier classifier,
ContactPointProtector protector,
SmtpProviderProperties properties,
Executor executor) {
this.dispatch = Objects.requireNonNull(dispatch, "dispatch");
this.mimeFactory = Objects.requireNonNull(mimeFactory, "mimeFactory");
this.classifier = Objects.requireNonNull(classifier, "classifier");
this.protector = Objects.requireNonNull(protector, "protector");
this.properties = Objects.requireNonNull(properties, "properties");
this.executor = Objects.requireNonNull(executor, "executor");
}
@Override
public ProviderId providerId() {
return PROVIDER_ID;
}
@Override
public Set<Channel> channels() {
return Set.of(Channel.EMAIL);
}
@Override
public ProviderCapabilities capabilities() {
// SMTP offers no status callback, no status query and no provider-side idempotency, so the
// runtime must never plan a reconciliation for it.
return new ProviderCapabilities(
false, false, false, false, false, false, false, false, 1, 25_000_000L, Duration.ofDays(1));
}
@Override
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
Objects.requireNonNull(submission, "submission");
return CompletableFuture.supplyAsync(() -> send(submission), executor);
}
private ProviderSubmissionResult send(ProviderSubmission submission) {
long startedNanos = System.nanoTime();
ContactPointValue value =
protector.reveal(
submission.contactPoint(),
AccessContext.dispatch(submission.profile().profileId().value()));
if (!(value instanceof EmailAddress address)) {
throw new IllegalArgumentException("SMTP requires an email contact point");
}
try {
dispatch.send(
mimeFactory.create(
submission, address.normalized(), properties.senderIdentity(), List.of()));
return ProviderSubmissionResult.accepted(null, "250", elapsedSince(startedNanos));
} catch (SmtpDispatchException failure) {
return classifier.classify(failure, elapsedSince(startedNanos));
}
}
private static Duration elapsedSince(long startedNanos) {
return Duration.ofNanos(System.nanoTime() - startedNanos);
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
import java.time.Duration;
import java.util.Objects;
/**
* SMTP profile.
*
* <p>Every timeout is required and finite. An unbounded SMTP read timeout is how one unresponsive
* relay turns into an exhausted dispatch pool.
*/
public record SmtpProviderProperties(
String host,
int port,
TlsMode tlsMode,
String senderIdentity,
Duration connectTimeout,
Duration readTimeout,
Duration writeTimeout,
int maxConcurrency) {
/** Transport security of the SMTP session. */
public enum TlsMode {
STARTTLS_REQUIRED,
IMPLICIT_TLS
}
public SmtpProviderProperties {
Objects.requireNonNull(host, "host");
Objects.requireNonNull(tlsMode, "tlsMode");
Objects.requireNonNull(senderIdentity, "senderIdentity");
requireFinite(connectTimeout, "connectTimeout");
requireFinite(readTimeout, "readTimeout");
requireFinite(writeTimeout, "writeTimeout");
if (host.isBlank()) {
throw new IllegalArgumentException("host");
}
if (port < 1 || port > 65535) {
throw new IllegalArgumentException("port");
}
if (maxConcurrency < 1) {
throw new IllegalArgumentException("maxConcurrency");
}
if (tlsMode == TlsMode.STARTTLS_REQUIRED && port == 25) {
// Port 25 with opportunistic STARTTLS is the classic silent-downgrade path; the profile has
// to say which it means.
throw new IllegalArgumentException("STARTTLS on port 25 must be declared explicitly");
}
}
private static void requireFinite(Duration timeout, String name) {
Objects.requireNonNull(timeout, name);
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException(name + " must be positive and finite");
}
}
}
@@ -0,0 +1,89 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.callback.CallbackRequest;
import dev.caskeleton.application.notification.platform.callback.CallbackVerificationResult;
import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter;
import dev.caskeleton.application.notification.platform.callback.VerifiedCallback;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/** Twilio status callback verification and normalization. */
public final class TwilioCallbackAdapter implements ProviderCallbackAdapter {
private static final ProviderId PROVIDER_ID = new ProviderId("twilio");
private final TwilioSignatureValidator validator;
private final TwilioStatusNormalizer normalizer;
private final TwilioProviderProperties properties;
private final SecretMaterialProvider secrets;
public TwilioCallbackAdapter(
TwilioSignatureValidator validator,
TwilioStatusNormalizer normalizer,
TwilioProviderProperties properties,
SecretMaterialProvider secrets) {
this.validator = Objects.requireNonNull(validator, "validator");
this.normalizer = Objects.requireNonNull(normalizer, "normalizer");
this.properties = Objects.requireNonNull(properties, "properties");
this.secrets = Objects.requireNonNull(secrets, "secrets");
}
@Override
public ProviderId providerId() {
return PROVIDER_ID;
}
@Override
public CallbackVerificationResult verify(CallbackRequest request) {
Objects.requireNonNull(request, "request");
Map<String, String> parameters = parseForm(request.body());
boolean valid =
validator.isValid(
properties.canonicalCallbackUrl(),
parameters,
request.header("x-twilio-signature").orElse(null),
secrets.activeKey(SecretPurpose.CALLBACK_SIGNING).material());
return valid
? CallbackVerificationResult.valid(new VerifiedCallback(request, parameters))
: CallbackVerificationResult.invalid("TWILIO_SIGNATURE_MISMATCH");
}
@Override
public List<NormalizedProviderEvent> normalize(VerifiedCallback callback) {
Objects.requireNonNull(callback, "callback");
Optional<Instant> occurredAt = Optional.of(callback.request().receivedAt());
return List.of(normalizer.normalize(callback.canonicalParameters(), occurredAt));
}
private static Map<String, String> parseForm(byte[] body) {
Map<String, String> parameters = new LinkedHashMap<>();
String raw = new String(body, StandardCharsets.UTF_8);
if (raw.isBlank()) {
return parameters;
}
for (String pair : java.util.regex.Pattern.compile("&").split(raw, -1)) {
if (pair.isEmpty()) {
continue;
}
int separator = pair.indexOf('=');
if (separator < 0) {
parameters.put(URLDecoder.decode(pair, StandardCharsets.UTF_8), "");
} else {
parameters.put(
URLDecoder.decode(pair.substring(0, separator), StandardCharsets.UTF_8),
URLDecoder.decode(pair.substring(separator + 1), StandardCharsets.UTF_8));
}
}
return parameters;
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.callback.StandardDeliveryProjector;
/**
* Twilio projector.
*
* <p>No provider-specific transitions are needed: the shared table already ignores a {@code sent}
* that follows a {@code delivered}, which is the exact Twilio behaviour this projector has to
* survive.
*/
public final class TwilioDeliveryProjector extends StandardDeliveryProjector {
public TwilioDeliveryProjector() {
super(new ProviderId("twilio"));
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio;
import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.provider.ProviderFailure;
import java.util.Optional;
import java.util.Set;
/** Maps Twilio error codes onto the stable failure vocabulary. */
public final class TwilioFailureClassifier {
/** Twilio error codes that identify the destination number rather than the request. */
private static final Set<Integer> INVALID_NUMBER_CODES =
Set.of(21211, 21214, 21610, 21612, 21614);
/** Classify a non-2xx Twilio response. */
public ProviderFailure classify(NotificationHttpResponse response) {
Optional<Integer> code = errorCode(response);
if (code.filter(INVALID_NUMBER_CODES::contains).isPresent()) {
return new ProviderFailure(
NotificationFailureCode.CONTACT_POINT_INVALID,
FailureCategory.INVALID_RECIPIENT,
false,
Optional.empty(),
code.map(String::valueOf));
}
if (response.statusCode() == 429) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_THROTTLED,
FailureCategory.THROTTLED,
true,
ProviderResults.retryAfter(response.header("retry-after")),
code.map(String::valueOf));
}
return ProviderResults.fromStatus(
response.statusCode(), ProviderResults.retryAfter(response.header("retry-after")));
}
private static Optional<Integer> errorCode(NotificationHttpResponse response) {
try {
var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString());
var code = node.get("code");
return code == null || code.isNull() ? Optional.empty() : Optional.of(code.asInt());
} catch (RuntimeException unparseable) {
return Optional.empty();
}
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio;
import java.net.URI;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
/**
* Twilio profile.
*
* <p>{@code canonicalCallbackUrl} is pinned here rather than reconstructed from the incoming
* request. Twilio signs the URL it called, and a reverse proxy that rewrites scheme or host makes a
* server-side reconstruction disagree with the signature — the most common cause of "valid webhook,
* failed verification".
*/
public record TwilioProviderProperties(
URI endpoint,
String accountSid,
Optional<String> messagingServiceSid,
Optional<String> fromNumber,
String canonicalCallbackUrl,
Duration timeout,
Duration maxReconciliationAge) {
public TwilioProviderProperties {
Objects.requireNonNull(endpoint, "endpoint");
Objects.requireNonNull(accountSid, "accountSid");
Objects.requireNonNull(messagingServiceSid, "messagingServiceSid");
Objects.requireNonNull(fromNumber, "fromNumber");
Objects.requireNonNull(canonicalCallbackUrl, "canonicalCallbackUrl");
Objects.requireNonNull(timeout, "timeout");
Objects.requireNonNull(maxReconciliationAge, "maxReconciliationAge");
if (accountSid.isBlank()) {
throw new IllegalArgumentException("accountSid");
}
if (messagingServiceSid.isEmpty() == fromNumber.isEmpty()) {
throw new IllegalArgumentException(
"exactly one of messagingServiceSid or fromNumber must be configured");
}
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("timeout must be positive and finite");
}
}
}
@@ -0,0 +1,138 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot;
import dev.caskeleton.application.notification.platform.provider.ReconciliationCapability;
import dev.caskeleton.application.notification.platform.provider.ReconciliationResult;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.Base64;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
/**
* Twilio message status polling.
*
* <p>Callbacks go missing. Twilio itself recommends polling when a status has not moved, so an
* attempt whose callback never arrived is corrected here rather than left ambiguous forever.
*
* <p>Two bounds keep the correction from becoming a second incident: an attempt older than the
* configured maximum is abandoned rather than polled indefinitely, and the query runs through the
* same gateway — and therefore the same provider rate budget — as dispatch.
*/
public final class TwilioReconciliationCapability implements ReconciliationCapability {
private final NotificationHttpGateway gateway;
private final TwilioProviderProperties properties;
private final TwilioStatusNormalizer normalizer;
private final SecretMaterialProvider secrets;
private final Clock clock;
public TwilioReconciliationCapability(
NotificationHttpGateway gateway,
TwilioProviderProperties properties,
TwilioStatusNormalizer normalizer,
SecretMaterialProvider secrets,
Clock clock) {
this.gateway = Objects.requireNonNull(gateway, "gateway");
this.properties = Objects.requireNonNull(properties, "properties");
this.normalizer = Objects.requireNonNull(normalizer, "normalizer");
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.clock = Objects.requireNonNull(clock, "clock");
}
@Override
public boolean supports(ProviderProfileSnapshot profile) {
Objects.requireNonNull(profile, "profile");
return profile.capabilities().statusQuery();
}
@Override
public CompletionStage<ReconciliationResult> reconcile(DeliveryAttemptSnapshot attempt) {
Objects.requireNonNull(attempt, "attempt");
Optional<String> messageSid = attempt.providerRequestId();
if (messageSid.isEmpty()) {
// Without a provider identifier there is nothing to ask about. This is the honest outcome of
// an ambiguous submission that never produced a SID, not a failure to try.
return CompletableFuture.completedFuture(new ReconciliationResult.Unsupported());
}
if (isTooOld(attempt)) {
return CompletableFuture.completedFuture(
new ReconciliationResult.Failed("RECONCILIATION_WINDOW_EXPIRED", false));
}
try {
NotificationHttpResponse response = gateway.exchange(statusRequest(messageSid.get()));
if (!response.isSuccessful()) {
return CompletableFuture.completedFuture(
new ReconciliationResult.Failed(
"STATUS_QUERY_" + response.statusCode(), response.statusCode() >= 500));
}
var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString());
String status =
Optional.ofNullable(node.get("status")).map(value -> value.asString()).orElse("unknown");
if (isPending(status)) {
return CompletableFuture.completedFuture(
new ReconciliationResult.StillUnknown(clock.instant().plusSeconds(300)));
}
return CompletableFuture.completedFuture(
new ReconciliationResult.Confirmed(
normalizer.normalize(
Map.of("MessageSid", messageSid.get(), "MessageStatus", status),
Optional.of(clock.instant()))));
} catch (NotificationHttpTransportException transportFailure) {
return CompletableFuture.completedFuture(
new ReconciliationResult.Failed("STATUS_QUERY_TRANSPORT", true));
}
}
private boolean isTooOld(DeliveryAttemptSnapshot attempt) {
return attempt.startedAt().plus(properties.maxReconciliationAge()).isBefore(clock.instant());
}
private static boolean isPending(String status) {
return switch (status) {
case "accepted", "queued", "sending", "scheduled" -> true;
default -> false;
};
}
private NotificationHttpRequest statusRequest(String messageSid) {
String credentials =
Base64.getEncoder()
.encodeToString(
(properties.accountSid()
+ ":"
+ new String(
secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material(),
StandardCharsets.UTF_8))
.getBytes(StandardCharsets.UTF_8));
return new NotificationHttpRequest(
"GET",
URI.create(
properties.endpoint()
+ "/2010-04-01/Accounts/"
+ properties.accountSid()
+ "/Messages/"
+ messageSid
+ ".json"),
JdkNotificationHttpGateway.headers(Map.of("authorization", "Basic " + credentials)),
new byte[0],
properties.timeout());
}
}
@@ -0,0 +1,73 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
import dev.caskeleton.application.notification.platform.api.content.SmsContent;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/** Builds the Twilio {@code Messages.json} form request. */
public final class TwilioRequestMapper {
private final TwilioProviderProperties properties;
public TwilioRequestMapper(TwilioProviderProperties properties) {
this.properties = Objects.requireNonNull(properties, "properties");
}
/** Map one submission into a form-encoded request. */
public NotificationHttpRequest map(
ProviderSubmission submission, String recipientE164, byte[] authToken) {
Objects.requireNonNull(submission, "submission");
if (!(submission.content().content() instanceof SmsContent sms)) {
throw new IllegalArgumentException("Twilio requires SMS content");
}
Map<String, String> form = new LinkedHashMap<>();
form.put("To", recipientE164);
properties.messagingServiceSid().ifPresent(sid -> form.put("MessagingServiceSid", sid));
properties.fromNumber().ifPresent(from -> form.put("From", from));
form.put("Body", sms.text());
form.put("StatusCallback", properties.canonicalCallbackUrl());
byte[] body = encode(form).getBytes(StandardCharsets.UTF_8);
String credentials =
Base64.getEncoder()
.encodeToString(
(properties.accountSid() + ":" + new String(authToken, StandardCharsets.UTF_8))
.getBytes(StandardCharsets.UTF_8));
return new NotificationHttpRequest(
"POST",
URI.create(
properties.endpoint()
+ "/2010-04-01/Accounts/"
+ properties.accountSid()
+ "/Messages.json"),
JdkNotificationHttpGateway.headers(
Map.of(
"content-type",
"application/x-www-form-urlencoded",
"authorization",
"Basic " + credentials)),
body,
properties.timeout());
}
private static String encode(Map<String, String> form) {
return form.entrySet().stream()
.map(
entry ->
URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)
+ "="
+ URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"));
}
}
@@ -0,0 +1,49 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.Map;
import java.util.TreeMap;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* {@code X-Twilio-Signature} validation.
*
* <p>The signature covers the full external URL followed by every POST parameter in sorted key
* order, concatenated as {@code key + value}. The URL is the one Twilio called, which is why the
* profile pins it rather than the adapter rebuilding it from proxy headers.
*/
public final class TwilioSignatureValidator {
/** Whether the presented signature matches. */
public boolean isValid(
String canonicalUrl,
Map<String, String> parameters,
String presentedSignature,
byte[] authToken) {
if (presentedSignature == null || presentedSignature.isBlank()) {
return false;
}
StringBuilder payload = new StringBuilder(canonicalUrl);
new TreeMap<>(parameters)
.forEach((key, value) -> payload.append(key).append(value == null ? "" : value));
try {
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(authToken, "HmacSHA1"));
String expected =
Base64.getEncoder()
.encodeToString(mac.doFinal(payload.toString().getBytes(StandardCharsets.UTF_8)));
// Constant-time comparison: a timing oracle on a webhook signature is a slow but real forgery
// path.
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
presentedSignature.getBytes(StandardCharsets.UTF_8));
} catch (GeneralSecurityException failure) {
return false;
}
}
}
@@ -0,0 +1,139 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio;
import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
import dev.caskeleton.application.notification.platform.contact.PhoneNumber;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import dev.caskeleton.application.notification.platform.security.AccessContext;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
/**
* Twilio Programmable Messaging submission.
*
* <p>{@code accepted}, {@code queued} and {@code sending} are all acceptance and nothing more.
* Twilio itself models {@code sent} and {@code delivered} as later, separate events, so this
* adapter never returns a delivery outcome — those arrive through status callbacks and
* reconciliation.
*/
public final class TwilioSmsProviderAdapter implements NotificationProviderAdapter {
private static final ProviderId PROVIDER_ID = new ProviderId("twilio");
private final NotificationHttpGateway gateway;
private final TwilioRequestMapper mapper;
private final TwilioFailureClassifier classifier;
private final ContactPointProtector protector;
private final SecretMaterialProvider secrets;
public TwilioSmsProviderAdapter(
NotificationHttpGateway gateway,
TwilioRequestMapper mapper,
TwilioFailureClassifier classifier,
ContactPointProtector protector,
SecretMaterialProvider secrets) {
this.gateway = Objects.requireNonNull(gateway, "gateway");
this.mapper = Objects.requireNonNull(mapper, "mapper");
this.classifier = Objects.requireNonNull(classifier, "classifier");
this.protector = Objects.requireNonNull(protector, "protector");
this.secrets = Objects.requireNonNull(secrets, "secrets");
}
@Override
public ProviderId providerId() {
return PROVIDER_ID;
}
@Override
public Set<Channel> channels() {
return Set.of(Channel.SMS);
}
@Override
public ProviderCapabilities capabilities() {
// statusCallback and statusQuery are both true: Twilio delivers status callbacks and also lets
// the platform poll, which is what makes missing-callback reconciliation possible.
return new ProviderCapabilities(
false, false, true, true, true, false, false, false, 1, 1_600L, Duration.ofHours(4));
}
@Override
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
Objects.requireNonNull(submission, "submission");
return CompletableFuture.completedFuture(send(submission));
}
private ProviderSubmissionResult send(ProviderSubmission submission) {
long startedNanos = System.nanoTime();
ContactPointValue value =
protector.reveal(
submission.contactPoint(),
AccessContext.dispatch(submission.profile().profileId().value()));
if (!(value instanceof PhoneNumber phone)) {
throw new IllegalArgumentException("Twilio requires a phone contact point");
}
var request =
mapper.map(
submission,
phone.e164(),
secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material());
try {
NotificationHttpResponse response = gateway.exchange(request);
Duration elapsed = elapsedSince(startedNanos);
if (!response.isSuccessful()) {
return ProviderSubmissionResult.rejected(classifier.classify(response), elapsed);
}
var parsed = parse(response);
return switch (parsed.status()) {
case "accepted", "queued", "sending", "scheduled" ->
ProviderSubmissionResult.accepted(parsed.sid(), parsed.status(), elapsed);
case "failed", "undelivered" ->
ProviderSubmissionResult.rejected(classifier.classify(response), elapsed);
default ->
// An unrecognised status is preserved verbatim rather than guessed at; the native value
// reaches the ledger and the stable vocabulary stays closed.
ProviderSubmissionResult.accepted(parsed.sid(), parsed.status(), elapsed);
};
} catch (NotificationHttpTransportException transportFailure) {
return ProviderResults.fromTransport(transportFailure, elapsedSince(startedNanos));
}
}
private static TwilioMessage parse(NotificationHttpResponse response) {
try {
var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString());
return new TwilioMessage(
Optional.ofNullable(node.get("sid")).map(value -> value.asString()).orElse(null),
Optional.ofNullable(node.get("status"))
.map(value -> value.asString())
.orElse("accepted"));
} catch (RuntimeException unparseable) {
return new TwilioMessage(null, "accepted");
}
}
private static Duration elapsedSince(long startedNanos) {
return Duration.ofNanos(System.nanoTime() - startedNanos);
}
/** Minimal projection of the Twilio message resource. */
private record TwilioMessage(String sid, String status) {}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio;
import dev.caskeleton.application.notification.platform.callback.NormalizedEventType;
import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
/**
* Twilio status to stable event.
*
* <p>The mapping is by status name, never by arrival time. Twilio does not guarantee callback
* ordering, so a {@code sent} that arrives after {@code delivered} has to be recognisable as the
* weaker fact it is.
*/
public final class TwilioStatusNormalizer {
/** Normalize one status callback. */
public NormalizedProviderEvent normalize(
Map<String, String> parameters, Optional<Instant> occurredAt) {
String status = parameters.getOrDefault("MessageStatus", "unknown");
Optional<String> sid = Optional.ofNullable(parameters.get("MessageSid"));
NormalizedEventType type =
switch (status) {
case "accepted", "queued", "scheduled", "sending" ->
NormalizedEventType.PROVIDER_ACCEPTED;
case "sent" -> NormalizedEventType.SENT;
case "delivered" -> NormalizedEventType.DELIVERY_CONFIRMED;
case "undelivered" -> NormalizedEventType.UNDELIVERED;
case "failed" -> NormalizedEventType.PROVIDER_REJECTED;
default -> NormalizedEventType.UNKNOWN;
};
Map<String, String> attributes =
parameters.containsKey("ErrorCode")
? Map.of("errorCode", parameters.get("ErrorCode"))
: Map.of();
return new NormalizedProviderEvent(type, status, Optional.empty(), sid, occurredAt, attributes);
}
}
@@ -0,0 +1,188 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.webhook;
import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
import dev.caskeleton.application.notification.platform.provider.ProviderFailure;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.function.Function;
/**
* Webhook extension.
*
* <p>Two gateways, chosen by whether the destination is operator configured or user supplied. A
* dynamic target never receives an {@code Authorization} or {@code Cookie} header, because a
* webhook pointed at an attacker's host would otherwise hand over whatever credential the trusted
* path uses.
*
* <p>An accepted body with no response is ambiguous here for the same reason as everywhere else:
* the receiver may already have acted on it.
*/
public final class WebhookNotificationProviderAdapter implements NotificationProviderAdapter {
private static final ProviderId PROVIDER_ID = new ProviderId("webhook");
private static final int MAX_DIAGNOSTIC_BODY = 512;
private final NotificationHttpGateway trustedGateway;
private final NotificationHttpGateway dynamicGateway;
private final WebhookSignatureStrategy signatures;
private final SecretMaterialProvider secrets;
private final Function<ProviderSubmission, WebhookSubscription> subscriptionResolver;
private final Duration timeout;
private final Clock clock;
public WebhookNotificationProviderAdapter(
NotificationHttpGateway trustedGateway,
NotificationHttpGateway dynamicGateway,
WebhookSignatureStrategy signatures,
SecretMaterialProvider secrets,
Function<ProviderSubmission, WebhookSubscription> subscriptionResolver,
Duration timeout,
Clock clock) {
this.trustedGateway = Objects.requireNonNull(trustedGateway, "trustedGateway");
this.dynamicGateway = Objects.requireNonNull(dynamicGateway, "dynamicGateway");
this.signatures = Objects.requireNonNull(signatures, "signatures");
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.subscriptionResolver =
Objects.requireNonNull(subscriptionResolver, "subscriptionResolver");
this.timeout = Objects.requireNonNull(timeout, "timeout");
this.clock = Objects.requireNonNull(clock, "clock");
}
@Override
public ProviderId providerId() {
return PROVIDER_ID;
}
@Override
public Set<Channel> channels() {
return Set.of(Channel.WEBHOOK);
}
@Override
public ProviderCapabilities capabilities() {
return new ProviderCapabilities(
false, false, false, false, false, false, false, false, 1, 1_000_000L, Duration.ofHours(1));
}
@Override
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
Objects.requireNonNull(submission, "submission");
return CompletableFuture.completedFuture(send(submission));
}
private ProviderSubmissionResult send(ProviderSubmission submission) {
long startedNanos = System.nanoTime();
WebhookSubscription subscription = subscriptionResolver.apply(submission);
byte[] body =
NotificationJsonMapper.mapper()
.writeValueAsString(
Map.of(
"attemptId", submission.attemptId().value().toString(),
"contentDigest", submission.content().contentDigest()))
.getBytes(StandardCharsets.UTF_8);
Map<String, String> headers = new LinkedHashMap<>();
headers.put("content-type", "application/json");
if (subscription.trusted() && subscription.signingKeyRef().isPresent()) {
var timestamp = clock.instant();
headers.put(
WebhookSignatureStrategy.TIMESTAMP_HEADER, Long.toString(timestamp.getEpochSecond()));
headers.put(
WebhookSignatureStrategy.SIGNATURE_HEADER,
signatures.sign(
body, timestamp, secrets.activeKey(SecretPurpose.CALLBACK_SIGNING).material()));
}
NotificationHttpRequest request =
new NotificationHttpRequest(
"POST",
subscription.target(),
JdkNotificationHttpGateway.headers(headers),
body,
timeout);
NotificationHttpGateway gateway = subscription.trusted() ? trustedGateway : dynamicGateway;
try {
NotificationHttpResponse response = gateway.exchange(request);
Duration elapsed = Duration.ofNanos(System.nanoTime() - startedNanos);
if (response.isSuccessful()) {
return ProviderSubmissionResult.accepted(
null, Integer.toString(response.statusCode()), elapsed);
}
// 429 is the receiver saying "later", not "no". Classifying it as permanent would drop a
// notification a working receiver explicitly asked us to resend, and no later evidence can
// tell that apart from a genuine rejection.
boolean throttled = response.statusCode() == 429;
boolean transientFailure = throttled || response.statusCode() >= 500;
return ProviderSubmissionResult.rejected(
new ProviderFailure(
NotificationFailureCode.PROVIDER_REJECTED,
throttled
? FailureCategory.THROTTLED
: transientFailure
? FailureCategory.TRANSIENT_PROVIDER
: FailureCategory.PERMANENT_PROVIDER,
transientFailure,
retryAfter(response),
Optional.of(boundedDiagnostic(response))),
elapsed);
} catch (NotificationHttpTransportException transportFailure) {
return ProviderResults.fromTransport(
transportFailure, Duration.ofNanos(System.nanoTime() - startedNanos));
}
}
/**
* The receiver's own backoff hint, when it sent a usable one.
*
* <p>Only the delta-seconds form is honoured. RFC 9110 also allows an HTTP-date, but a receiver
* whose clock disagrees with ours would then dictate a wait computed from the difference — which
* is how one misconfigured subscriber stalls a queue.
*/
private static Optional<Duration> retryAfter(NotificationHttpResponse response) {
return response
.header("retry-after")
.flatMap(
value -> {
try {
long seconds = Long.parseLong(value.trim());
return seconds > 0 ? Optional.of(Duration.ofSeconds(seconds)) : Optional.empty();
} catch (NumberFormatException notDeltaSeconds) {
return Optional.empty();
}
});
}
/** Only a bounded slice of the response is kept; a receiver's body is not our log. */
private static String boundedDiagnostic(NotificationHttpResponse response) {
String body = response.bodyAsString();
return response.statusCode()
+ ":"
+ body.substring(0, Math.min(body.length(), MAX_DIAGNOSTIC_BODY));
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.webhook;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Objects;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* Request signing for trusted webhook subscriptions.
*
* <p>The timestamp is inside the signed payload, so a captured request cannot be replayed later
* without the receiver noticing the skew.
*/
public final class WebhookSignatureStrategy {
/** Header carrying the signature. */
public static final String SIGNATURE_HEADER = "x-notification-signature";
/** Header carrying the signed timestamp. */
public static final String TIMESTAMP_HEADER = "x-notification-timestamp";
/** Compute the signature over timestamp and body. */
public String sign(byte[] body, Instant timestamp, byte[] signingKey) {
Objects.requireNonNull(body, "body");
Objects.requireNonNull(timestamp, "timestamp");
Objects.requireNonNull(signingKey, "signingKey");
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(signingKey, "HmacSHA256"));
mac.update(Long.toString(timestamp.getEpochSecond()).getBytes(StandardCharsets.US_ASCII));
mac.update((byte) '.');
return "v1=" + HexFormat.of().formatHex(mac.doFinal(body));
} catch (GeneralSecurityException failure) {
throw new IllegalStateException("webhook signing failed", failure);
}
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.webhook;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationEndpoints;
import java.net.URI;
import java.util.Objects;
import java.util.Optional;
/**
* A webhook destination.
*
* <p>{@code trusted} decides which gateway carries the call. A trusted subscription is operator
* configured and may use platform credentials; a dynamic one comes from user input and must not
* inherit anything, because that is how a webhook feature becomes an SSRF credential-relay.
*/
public record WebhookSubscription(
String subscriptionId, URI target, boolean trusted, Optional<String> signingKeyRef) {
public WebhookSubscription {
Objects.requireNonNull(subscriptionId, "subscriptionId");
Objects.requireNonNull(target, "target");
Objects.requireNonNull(signingKeyRef, "signingKeyRef");
if (subscriptionId.isBlank()) {
throw new IllegalArgumentException("subscriptionId");
}
NotificationEndpoints.requireSecureOrLoopback(target, "webhook target");
if (!trusted && signingKeyRef.isPresent()) {
throw new IllegalArgumentException(
"a dynamic target may not be paired with a platform signing key");
}
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush;
import java.util.Arrays;
import java.util.Objects;
/** An RFC 8291 {@code aes128gcm} record ready to be sent as the request body. */
@SuppressWarnings("ArrayRecordComponent") // defensive copies on construction and on every accessor
public record EncryptedWebPushPayload(byte[] body, String contentEncoding) {
public EncryptedWebPushPayload {
Objects.requireNonNull(body, "body");
Objects.requireNonNull(contentEncoding, "contentEncoding");
body = body.clone();
}
@Override
public byte[] body() {
return body.clone();
}
@Override
public boolean equals(Object other) {
return other instanceof EncryptedWebPushPayload payload
&& Arrays.equals(body, payload.body)
&& contentEncoding.equals(payload.contentEncoding);
}
@Override
public int hashCode() {
return Objects.hash(Arrays.hashCode(body), contentEncoding);
}
@Override
public String toString() {
return "EncryptedWebPushPayload[encoding=" + contentEncoding + ", bytes=" + body.length + "]";
}
}
@@ -0,0 +1,193 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush;
import dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.AlgorithmParameters;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECGenParameterSpec;
import java.security.spec.ECParameterSpec;
import java.security.spec.ECPoint;
import java.security.spec.ECPublicKeySpec;
import java.util.Objects;
import javax.crypto.Cipher;
import javax.crypto.KeyAgreement;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
* RFC 8291 Web Push payload encryption.
*
* <p>The subscription's public key and auth secret are the only inputs the application server has,
* and the sequence — ECDH, then HKDF keyed by the auth secret, then HKDF keyed by a fresh salt — is
* what binds the ciphertext to that one subscriber. A fresh ephemeral key pair per message is not
* an optimisation choice: reusing one would let two messages to the same subscriber share a key
* stream.
*/
public final class Rfc8291Aes128GcmEncryptor {
private static final int SALT_BYTES = 16;
private static final int KEY_BYTES = 16;
private static final int NONCE_BYTES = 12;
private static final int RECORD_SIZE = 4096;
private static final int TAG_BITS = 128;
private static final byte PADDING_DELIMITER = 0x02;
private final SecureRandom random;
public Rfc8291Aes128GcmEncryptor() {
this(new SecureRandom());
}
public Rfc8291Aes128GcmEncryptor(SecureRandom random) {
this.random = Objects.requireNonNull(random, "random");
}
/** Encrypt one payload for one subscription. */
public EncryptedWebPushPayload encrypt(WebPushSubscriptionValue subscription, byte[] plaintext) {
Objects.requireNonNull(subscription, "subscription");
Objects.requireNonNull(plaintext, "plaintext");
if (plaintext.length + 1 > RECORD_SIZE - 16 - 5 - 65 - 16) {
throw new IllegalArgumentException("payload exceeds the Web Push record size");
}
try {
byte[] userAgentPublic = subscription.p256dh();
byte[] authSecret = subscription.authSecret();
KeyPair ephemeral = generateP256KeyPair();
byte[] applicationServerPublic = encodePoint((ECPublicKey) ephemeral.getPublic());
byte[] sharedSecret = agree(ephemeral, decodePoint(userAgentPublic));
byte[] ikm =
hkdf(
authSecret,
sharedSecret,
concat(
"WebPush: info\0".getBytes(StandardCharsets.US_ASCII),
userAgentPublic,
applicationServerPublic),
32);
byte[] salt = new byte[SALT_BYTES];
random.nextBytes(salt);
byte[] contentEncryptionKey =
hkdf(
salt,
ikm,
"Content-Encoding: aes128gcm\0".getBytes(StandardCharsets.US_ASCII),
KEY_BYTES);
byte[] nonce =
hkdf(
salt,
ikm,
"Content-Encoding: nonce\0".getBytes(StandardCharsets.US_ASCII),
NONCE_BYTES);
byte[] padded = concat(plaintext, new byte[] {PADDING_DELIMITER});
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(
Cipher.ENCRYPT_MODE,
new SecretKeySpec(contentEncryptionKey, "AES"),
new GCMParameterSpec(TAG_BITS, nonce));
byte[] ciphertext = cipher.doFinal(padded);
ByteArrayOutputStream body = new ByteArrayOutputStream();
body.writeBytes(salt);
body.writeBytes(ByteBuffer.allocate(4).putInt(RECORD_SIZE).array());
body.write(applicationServerPublic.length);
body.writeBytes(applicationServerPublic);
body.writeBytes(ciphertext);
return new EncryptedWebPushPayload(body.toByteArray(), "aes128gcm");
} catch (GeneralSecurityException failure) {
throw new IllegalStateException("Web Push payload encryption failed", failure);
}
}
/** HKDF with SHA-256, as used throughout RFC 8291. */
public static byte[] hkdf(byte[] salt, byte[] ikm, byte[] info, int length) {
try {
Mac extract = Mac.getInstance("HmacSHA256");
extract.init(new SecretKeySpec(salt, "HmacSHA256"));
byte[] prk = extract.doFinal(ikm);
Mac expand = Mac.getInstance("HmacSHA256");
expand.init(new SecretKeySpec(prk, "HmacSHA256"));
expand.update(info);
expand.update((byte) 1);
byte[] okm = expand.doFinal();
return java.util.Arrays.copyOf(okm, length);
} catch (GeneralSecurityException failure) {
throw new IllegalStateException("HKDF failed", failure);
}
}
private static KeyPair generateP256KeyPair() throws GeneralSecurityException {
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(new ECGenParameterSpec("secp256r1"));
return generator.generateKeyPair();
}
private static byte[] agree(KeyPair ephemeral, PublicKey peer) throws GeneralSecurityException {
KeyAgreement agreement = KeyAgreement.getInstance("ECDH");
agreement.init(ephemeral.getPrivate());
agreement.doPhase(peer, true);
return agreement.generateSecret();
}
/** Uncompressed SEC1 encoding of a P-256 public key. */
public static byte[] encodePoint(ECPublicKey key) {
byte[] x = unsigned(key.getW().getAffineX(), 32);
byte[] y = unsigned(key.getW().getAffineY(), 32);
byte[] encoded = new byte[65];
encoded[0] = 0x04;
System.arraycopy(x, 0, encoded, 1, 32);
System.arraycopy(y, 0, encoded, 33, 32);
return encoded;
}
/** Decode an uncompressed SEC1 P-256 point. */
public static PublicKey decodePoint(byte[] encoded) throws GeneralSecurityException {
if (encoded.length != 65 || encoded[0] != 0x04) {
throw new GeneralSecurityException("expected an uncompressed P-256 point");
}
BigInteger x = new BigInteger(1, java.util.Arrays.copyOfRange(encoded, 1, 33));
BigInteger y = new BigInteger(1, java.util.Arrays.copyOfRange(encoded, 33, 65));
AlgorithmParameters parameters = AlgorithmParameters.getInstance("EC");
parameters.init(new ECGenParameterSpec("secp256r1"));
ECParameterSpec spec = parameters.getParameterSpec(ECParameterSpec.class);
return KeyFactory.getInstance("EC")
.generatePublic(new ECPublicKeySpec(new ECPoint(x, y), spec));
}
private static byte[] unsigned(BigInteger value, int length) {
byte[] raw = value.toByteArray();
if (raw.length == length) {
return raw;
}
byte[] fixed = new byte[length];
if (raw.length > length) {
System.arraycopy(raw, raw.length - length, fixed, 0, length);
} else {
System.arraycopy(raw, 0, fixed, length - raw.length, raw.length);
}
return fixed;
}
private static byte[] concat(byte[]... parts) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
for (byte[] part : parts) {
out.writeBytes(part);
}
return out.toByteArray();
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush;
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
import java.net.URI;
/**
* Supplies the {@code Authorization} header for a Web Push request.
*
* <p>An interface rather than the signer itself because VAPID is one identification scheme among
* several a push service may accept, and because the transport behaviour — TTL, urgency, status
* mapping — has to be testable without a real EC private key.
*/
public interface VapidAuthorizationProvider {
/** Header value for one endpoint. */
String authorization(URI endpoint, SecretKeyMaterial signingKey, String publicKeyBase64Url);
}
@@ -0,0 +1,122 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush;
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Clock;
import java.time.Duration;
import java.util.Base64;
import java.util.Objects;
/**
* RFC 8292 VAPID JWT.
*
* <p>The audience is the origin of the endpoint the request is going to, not a configured constant.
* A token minted for one push service and replayed at another is exactly what audience binding
* prevents.
*
* <p>The signature is converted from the JVM's DER encoding to the 64-byte JOSE form, because ES256
* in JWT is fixed-width {@code r || s}.
*/
public final class VapidJwtSigner implements VapidAuthorizationProvider {
private static final Duration MAX_LIFETIME = Duration.ofHours(12);
private final Clock clock;
private final String subject;
public VapidJwtSigner(Clock clock, String subject) {
this.clock = Objects.requireNonNull(clock, "clock");
this.subject = Objects.requireNonNull(subject, "subject");
if (!subject.startsWith("mailto:") && !subject.startsWith("https://")) {
throw new IllegalArgumentException("VAPID subject must be a mailto: or https: URI");
}
}
/** Sign a token for one endpoint. */
public String sign(URI endpoint, SecretKeyMaterial signingKey, Duration lifetime) {
Objects.requireNonNull(endpoint, "endpoint");
Objects.requireNonNull(signingKey, "signingKey");
Objects.requireNonNull(lifetime, "lifetime");
if (lifetime.compareTo(MAX_LIFETIME) > 0) {
throw new IllegalArgumentException("VAPID token lifetime must not exceed 12 hours");
}
String audience = endpoint.getScheme() + "://" + endpoint.getHost();
String header = base64Url("{\"typ\":\"JWT\",\"alg\":\"ES256\"}");
String payload =
base64Url(
"{\"aud\":\""
+ audience
+ "\",\"exp\":"
+ clock.instant().plus(lifetime).getEpochSecond()
+ ",\"sub\":\""
+ subject
+ "\"}");
String signingInput = header + "." + payload;
try {
PrivateKey privateKey =
KeyFactory.getInstance("EC")
.generatePrivate(new PKCS8EncodedKeySpec(signingKey.material()));
Signature signature = Signature.getInstance("SHA256withECDSA");
signature.initSign(privateKey);
signature.update(signingInput.getBytes(StandardCharsets.US_ASCII));
byte[] jose = derToJose(signature.sign());
return signingInput + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(jose);
} catch (GeneralSecurityException failure) {
throw new IllegalStateException("VAPID signing failed", failure);
}
}
/** Full {@code Authorization} header value for a request. */
@Override
public String authorization(
URI endpoint, SecretKeyMaterial signingKey, String publicKeyBase64Url) {
return "vapid t="
+ sign(endpoint, signingKey, Duration.ofHours(1))
+ ", k="
+ publicKeyBase64Url;
}
private static String base64Url(String json) {
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(json.getBytes(StandardCharsets.UTF_8));
}
/** DER {@code SEQUENCE{INTEGER r, INTEGER s}} to fixed-width {@code r || s}. */
private static byte[] derToJose(byte[] der) throws GeneralSecurityException {
if (der.length < 8 || der[0] != 0x30) {
throw new GeneralSecurityException("unexpected ECDSA signature encoding");
}
int offset = der[1] == (byte) 0x81 ? 3 : 2;
if (der[offset] != 0x02) {
throw new GeneralSecurityException("unexpected ECDSA signature encoding");
}
int rLength = der[offset + 1];
int rStart = offset + 2;
int sLengthOffset = rStart + rLength;
if (der[sLengthOffset] != 0x02) {
throw new GeneralSecurityException("unexpected ECDSA signature encoding");
}
int sLength = der[sLengthOffset + 1];
int sStart = sLengthOffset + 2;
byte[] jose = new byte[64];
copyFixed(der, rStart, rLength, jose, 0);
copyFixed(der, sStart, sLength, jose, 32);
return jose;
}
private static void copyFixed(byte[] source, int start, int length, byte[] target, int offset) {
int copyLength = Math.min(length, 32);
int sourceStart = start + length - copyLength;
System.arraycopy(source, sourceStart, target, offset + 32 - copyLength, copyLength);
}
}
@@ -0,0 +1,88 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush;
import dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue;
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.util.Map;
import java.util.Objects;
/**
* The application server keys a push service will accept, keyed by VAPID key id.
*
* <p>Under RFC 8292 a subscription is created against one application server key. The user agent
* remembers it, and a push signed by a different key is rejected — so a VAPID rotation is not a
* server-side credential swap, it is a client migration that only completes when every subscriber's
* user agent re-subscribes.
*
* <p>That is why this registry keeps <em>every</em> key that still has live subscriptions rather
* than only the current one, and why {@link #requiresSubscriptionMigration} exists: the state has
* to be visible to operators, because the only way out of it is to prompt users to re-subscribe.
* Silently signing with the new key would look like a successful rotation and deliver nothing.
*/
public final class VapidKeyRegistry {
private final SecretMaterialProvider secrets;
private final String activeKeyId;
private final Map<String, String> publicKeysByKeyId;
/**
* @param secrets source of the EC private keys; never a config property or a file
* @param activeKeyId key id new subscriptions are created against
* @param publicKeysByKeyId base64url-encoded uncompressed P-256 public keys, per key id
*/
public VapidKeyRegistry(
SecretMaterialProvider secrets, String activeKeyId, Map<String, String> publicKeysByKeyId) {
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.activeKeyId = Objects.requireNonNull(activeKeyId, "activeKeyId");
this.publicKeysByKeyId = Map.copyOf(Objects.requireNonNull(publicKeysByKeyId, "publicKeys"));
if (!this.publicKeysByKeyId.containsKey(activeKeyId)) {
throw new IllegalArgumentException("no public key registered for the active VAPID key id");
}
}
/** Key id new subscriptions should be created against. */
public String activeKeyId() {
return activeKeyId;
}
/** Base64url public key advertised to the user agent for a key id. */
public String publicKey(String keyId) {
String publicKey = publicKeysByKeyId.get(Objects.requireNonNull(keyId, "keyId"));
if (publicKey == null) {
throw new IllegalStateException("unknown VAPID key id");
}
return publicKey;
}
/**
* Private key for the id a subscription was created with — never the active key.
*
* <p>Falling back to the active key here is the tempting shortcut and the wrong one: the push
* service would reject the token, and the failure would look like an invalid subscription rather
* than a misconfigured rotation.
*/
public SecretKeyMaterial signingKeyFor(WebPushSubscriptionValue subscription) {
Objects.requireNonNull(subscription, "subscription");
SecretKeyMaterial key = secrets.keyById(subscription.vapidKeyId());
if (key.purpose() != SecretPurpose.VAPID_SIGNING) {
throw new IllegalStateException("key is not a VAPID signing key");
}
return key;
}
/** Public key belonging to the subscription's own key id. */
public String publicKeyFor(WebPushSubscriptionValue subscription) {
return publicKey(Objects.requireNonNull(subscription, "subscription").vapidKeyId());
}
/**
* True when this subscription is still bound to a superseded key.
*
* <p>Sends keep working — they are signed with the old key, which is why it is still registered —
* but the subscription cannot be considered migrated until the user agent re-subscribes.
*/
public boolean requiresSubscriptionMigration(WebPushSubscriptionValue subscription) {
return !activeKeyId.equals(Objects.requireNonNull(subscription, "subscription").vapidKeyId());
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush;
import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.provider.ProviderFailure;
import java.util.Optional;
/**
* Web Push status classification.
*
* <p>RFC 8030 defines {@code 404} for an expired subscription. Several push services return {@code
* 410} instead, so both are treated as invalidation — hard-coding only the one a particular browser
* happens to send is how subscriptions accumulate forever.
*/
public final class WebPushFailureClassifier {
/** Classify a non-2xx push-service response. */
public ProviderFailure classify(NotificationHttpResponse response) {
int status = response.statusCode();
if (status == 404 || status == 410) {
return new ProviderFailure(
NotificationFailureCode.CONTACT_POINT_INVALID,
FailureCategory.INVALID_RECIPIENT,
false,
Optional.empty(),
Optional.of(Integer.toString(status)));
}
if (status == 413) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT,
FailureCategory.INVALID_PAYLOAD,
false,
Optional.empty(),
Optional.of("413"));
}
return ProviderResults.fromStatus(
status, ProviderResults.retryAfter(response.header("retry-after")));
}
}
@@ -0,0 +1,111 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush;
import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
import dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import dev.caskeleton.application.notification.platform.security.AccessContext;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import java.time.Duration;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
/**
* Web Push transport.
*
* <p>A {@code 201} is push-service acceptance. RFC 8030 keeps user-agent acknowledgement in a
* separate receipt mechanism, so this adapter only reports {@code DEVICE_DELIVERED} where the
* profile declares that the service actually offers receipts.
*/
public final class WebPushNotificationProviderAdapter implements NotificationProviderAdapter {
private static final ProviderId PROVIDER_ID = new ProviderId("webpush");
private final NotificationHttpGateway gateway;
private final WebPushRequestMapper mapper;
private final WebPushFailureClassifier classifier;
private final ContactPointProtector protector;
private final WebPushProviderProperties properties;
public WebPushNotificationProviderAdapter(
NotificationHttpGateway gateway,
WebPushRequestMapper mapper,
WebPushFailureClassifier classifier,
ContactPointProtector protector,
WebPushProviderProperties properties) {
this.gateway = Objects.requireNonNull(gateway, "gateway");
this.mapper = Objects.requireNonNull(mapper, "mapper");
this.classifier = Objects.requireNonNull(classifier, "classifier");
this.protector = Objects.requireNonNull(protector, "protector");
this.properties = Objects.requireNonNull(properties, "properties");
}
@Override
public ProviderId providerId() {
return PROVIDER_ID;
}
@Override
public Set<Channel> channels() {
return Set.of(Channel.WEB_PUSH);
}
@Override
public ProviderCapabilities capabilities() {
return new ProviderCapabilities(
false,
false,
properties.receiptsSupported(),
false,
properties.receiptsSupported(),
false,
false,
true,
1,
properties.maxPayloadBytes(),
properties.maxTtl());
}
@Override
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
Objects.requireNonNull(submission, "submission");
return CompletableFuture.completedFuture(send(submission));
}
private ProviderSubmissionResult send(ProviderSubmission submission) {
long startedNanos = System.nanoTime();
ContactPointValue value =
protector.reveal(
submission.contactPoint(),
AccessContext.dispatch(submission.profile().profileId().value()));
if (!(value instanceof WebPushSubscriptionValue subscription)) {
throw new IllegalArgumentException("Web Push requires a subscription contact point");
}
var request = mapper.map(submission, subscription);
try {
NotificationHttpResponse response = gateway.exchange(request);
Duration elapsed = Duration.ofNanos(System.nanoTime() - startedNanos);
if (response.isSuccessful()) {
return ProviderSubmissionResult.accepted(
response.header("location").orElse(null),
Integer.toString(response.statusCode()),
elapsed);
}
return ProviderSubmissionResult.rejected(classifier.classify(response), elapsed);
} catch (NotificationHttpTransportException transportFailure) {
return ProviderResults.fromTransport(
transportFailure, Duration.ofNanos(System.nanoTime() - startedNanos));
}
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush;
import java.time.Duration;
import java.util.Objects;
/**
* Web Push profile.
*
* <p>{@code receiptsSupported} defaults to false. RFC 8030 defines delivery receipts, but not every
* push service implements them, and assuming one exists would mean waiting forever for a receipt
* that is never coming.
*/
public record WebPushProviderProperties(
String vapidPublicKeyBase64Url,
Duration maxTtl,
long maxPayloadBytes,
boolean receiptsSupported,
Duration timeout) {
/** RFC 8291 does not require a push service to accept more than this. */
public static final long RFC_8291_MAX_BODY_BYTES = 4096L;
public WebPushProviderProperties {
Objects.requireNonNull(vapidPublicKeyBase64Url, "vapidPublicKeyBase64Url");
Objects.requireNonNull(maxTtl, "maxTtl");
Objects.requireNonNull(timeout, "timeout");
if (vapidPublicKeyBase64Url.isBlank()) {
throw new IllegalArgumentException("vapidPublicKeyBase64Url");
}
if (maxPayloadBytes < 1 || maxPayloadBytes > RFC_8291_MAX_BODY_BYTES) {
throw new IllegalArgumentException("maxPayloadBytes must be 1.." + RFC_8291_MAX_BODY_BYTES);
}
if (maxTtl.isNegative() || maxTtl.isZero() || timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("maxTtl and timeout must be positive and finite");
}
}
}
@@ -0,0 +1,97 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush;
import java.net.URI;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
/**
* RFC 8030 §8 delivery receipts, treated as optional because they are.
*
* <p>A receipt subscription is the only way Web Push can report {@code DEVICE_DELIVERED} rather
* than {@code PROVIDER_ACCEPTED}. But RFC 8030 does not require a push service to implement it, and
* the major ones largely do not — so the capability has to be declared per profile and confirmed by
* the response, never assumed. Assuming it would leave deliveries parked forever waiting on a
* receipt that is not coming, which is worse than honestly reporting acceptance.
*/
public final class WebPushReceiptCapability {
/** Link relation a push service uses to hand back a receipt subscription. */
public static final String RECEIPT_LINK_RELATION = "urn:ietf:params:push:receipt";
private final boolean requested;
private WebPushReceiptCapability(boolean requested) {
this.requested = requested;
}
/** Capability derived from the profile's declared support. */
public static WebPushReceiptCapability forProfile(WebPushProviderProperties properties) {
return new WebPushReceiptCapability(
Objects.requireNonNull(properties, "properties").receiptsSupported());
}
/** Never request receipts. */
public static WebPushReceiptCapability unsupported() {
return new WebPushReceiptCapability(false);
}
/** True when a receipt should be requested for this profile. */
public boolean requested() {
return requested;
}
/**
* The {@code Prefer} header value, if any.
*
* <p>Empty rather than a no-op header: sending {@code Prefer: respond-async} to a service that
* does not implement receipts invites a 4xx from strict implementations for no benefit.
*/
public Optional<String> preferHeader() {
return requested ? Optional.of("respond-async") : Optional.empty();
}
/**
* Receipt subscription URI advertised by the push service, if it advertised one.
*
* <p>A response that carries no receipt link is the normal case, not an error. It means the send
* was accepted and delivery evidence will never rise above acceptance for this message.
*
* @param linkHeaders raw {@code Link} response header values
*/
public Optional<URI> receiptSubscription(List<String> linkHeaders) {
Objects.requireNonNull(linkHeaders, "linkHeaders");
if (!requested) {
return Optional.empty();
}
for (String header : linkHeaders) {
for (String link : header.split(",", -1)) {
Optional<URI> receipt = parseReceiptLink(link);
if (receipt.isPresent()) {
return receipt;
}
}
}
return Optional.empty();
}
private static Optional<URI> parseReceiptLink(String link) {
String candidate = link.trim();
int start = candidate.indexOf('<');
int end = candidate.indexOf('>');
if (start < 0 || end <= start) {
return Optional.empty();
}
String parameters = candidate.substring(end + 1).toLowerCase(Locale.ROOT).replace("\"", "");
if (!parameters.contains("rel=" + RECEIPT_LINK_RELATION)) {
return Optional.empty();
}
try {
return Optional.of(URI.create(candidate.substring(start + 1, end).trim()));
} catch (IllegalArgumentException malformed) {
// A malformed link is not worth failing an accepted send over; it only costs the receipt.
return Optional.empty();
}
}
}
@@ -0,0 +1,117 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.content.WebPushContent;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationExpiredException;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.api.error.ProviderConfigurationException;
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
import dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* Builds the RFC 8030 request.
*
* <p>{@code TTL} is mandatory by protocol, and a submission with no expiry cannot produce one. That
* is a configuration failure rather than a silent default, because a guessed TTL would decide how
* long a push service keeps a message the platform has no opinion about.
*/
public final class WebPushRequestMapper {
private final Rfc8291Aes128GcmEncryptor encryptor;
private final VapidAuthorizationProvider signer;
private final SecretMaterialProvider secrets;
private final WebPushProviderProperties properties;
private final Clock clock;
public WebPushRequestMapper(
Rfc8291Aes128GcmEncryptor encryptor,
VapidAuthorizationProvider signer,
SecretMaterialProvider secrets,
WebPushProviderProperties properties,
Clock clock) {
this.encryptor = Objects.requireNonNull(encryptor, "encryptor");
this.signer = Objects.requireNonNull(signer, "signer");
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.properties = Objects.requireNonNull(properties, "properties");
this.clock = Objects.requireNonNull(clock, "clock");
}
/** Map one submission into a Web Push request. */
public NotificationHttpRequest map(
ProviderSubmission submission, WebPushSubscriptionValue subscription) {
Objects.requireNonNull(submission, "submission");
Objects.requireNonNull(subscription, "subscription");
if (submission.expiresAt().isEmpty()) {
throw new ProviderConfigurationException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID,
FailureCategory.INVALID_PAYLOAD));
}
Duration ttl = Duration.between(clock.instant(), submission.expiresAt().get());
if (ttl.isNegative() || ttl.isZero()) {
throw new NotificationExpiredException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.NOTIFICATION_EXPIRED, FailureCategory.EXPIRED));
}
if (ttl.compareTo(properties.maxTtl()) > 0) {
ttl = properties.maxTtl();
}
if (!(submission.content().content() instanceof WebPushContent content)) {
throw new IllegalArgumentException("Web Push requires Web Push content");
}
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("title", content.title());
payload.put("body", content.body());
content.deepLink().ifPresent(link -> payload.put("deepLink", link.toString()));
if (!content.data().isEmpty()) {
payload.put("data", content.data());
}
var encrypted =
encryptor.encrypt(
subscription,
NotificationJsonMapper.mapper()
.writeValueAsString(payload)
.getBytes(StandardCharsets.UTF_8));
if (encrypted.body().length > properties.maxPayloadBytes()) {
throw new ProviderPayloadLimitException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, FailureCategory.INVALID_PAYLOAD));
}
Map<String, String> headers = new LinkedHashMap<>();
headers.put("ttl", Long.toString(ttl.toSeconds()));
headers.put("content-encoding", encrypted.contentEncoding());
headers.put("content-type", "application/octet-stream");
headers.put("urgency", content.options().urgency().headerValue());
content.options().topic().ifPresent(topic -> headers.put("topic", topic));
headers.put(
"authorization",
signer.authorization(
subscription.endpoint(),
secrets.activeKey(SecretPurpose.VAPID_SIGNING),
properties.vapidPublicKeyBase64Url()));
return new NotificationHttpRequest(
"POST",
subscription.endpoint(),
JdkNotificationHttpGateway.headers(headers),
encrypted.body(),
properties.timeout());
}
}

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