feat: add notification production capability
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -12,10 +12,28 @@ Package root: `dev.caskeleton.adapter.outbound.notification`.
|
||||
|
||||
## Responsibility
|
||||
|
||||
- Implement notification provider routing and provider-specific Slack/email clients behind ports.
|
||||
- Own provider settings, technical fallback, and provider adaptation.
|
||||
- Preserve the current raw notification router/provider seams only as the `R0 legacy` compatibility
|
||||
baseline until the reviewed canonical cutover removes them.
|
||||
- Implement future provider protocols behind application-owned ports without leaking SDK, transport,
|
||||
bootstrap, or persistence types.
|
||||
- Own provider settings, technical fallback mechanics, and provider adaptation; application policy
|
||||
owns mode, eligibility, retry/fallback decisions and business failure semantics.
|
||||
- Reuse `adapter:outbound:support` for shared outbound concerns.
|
||||
|
||||
## Current R0 freeze
|
||||
|
||||
- `RoutingNotifier` performs route-list fan-out over `(Channel, providerId)`.
|
||||
- `FailOpenNotificationProvider` applies one global fail-open rule.
|
||||
- `google-email`/`GoogleEmailClient` and `slack-webhook`/`SlackClient` are fake-only extension seams,
|
||||
not production integrations or qualified provider cards.
|
||||
- Checked-in provider selector keys drift from the router's `routes` + provider `enabled` grammar.
|
||||
Preserve and document that drift until the canonical graph replaces it; do not silently reinterpret
|
||||
the old keys.
|
||||
- There are no feature/application production consumers and no real-provider, durable, receipt,
|
||||
security, load, or rotation evidence.
|
||||
- The exact legacy deletion inventory lives in [README.md](README.md). Do not add behavior to those
|
||||
classes while building their canonical replacements.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Allowed dependency edges come only from the module's
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
# adapter:outbound:notification — 설계 결정 참조
|
||||
# adapter:outbound:notification — R0 legacy truth
|
||||
|
||||
> 현재 구현 전체는 교체 전 호환성 기준선인 `R0 legacy`다. `GoogleEmailClient`와
|
||||
> `SlackClient`는 project-supplied seam일 뿐 실제 Google Mail 또는 Slack 연동이 아니며,
|
||||
> provider/card qualification evidence도 없다.
|
||||
|
||||
알림(email/Slack 등) 아웃바운드 어댑터 모듈. 패키지 루트:
|
||||
`dev.caskeleton.adapter.outbound.notification`. `:adapter:outbound:support` 에 의존해 공유
|
||||
correlation / fail-open 의존성 로깅을 재사용한다.
|
||||
|
||||
허용/금지 의존 정책은 `src/build.gradle` 의
|
||||
`allowedProjectDependencies['adapter:outbound:notification']` 항목이 SSOT 다(이 모듈은 아직
|
||||
별도 CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔
|
||||
참조용 기록이다.
|
||||
허용/금지 의존 정책은 `src/config/architecture/modules.json`의
|
||||
`adapter-outbound-notification` row가 SSOT다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의
|
||||
근거**와 canonical 구현 전 삭제 대상을 모아둔 참조용 기록이다.
|
||||
|
||||
## Task 1 R0 truth table
|
||||
|
||||
| 축 | 현재 사실 | 증거와 한계 |
|
||||
| --- | --- | --- |
|
||||
| application contract | raw `NotificationPort.notify(Channel, route, Notification)` | mode, transaction, receipt, attempt certainty가 없는 R0 port |
|
||||
| routing | `(channel, providerId)` registry + route별 provider ID list fan-out | `RoutingNotifierTest`; route list의 모든 provider를 순서대로 호출 |
|
||||
| provider failure | 모든 provider를 중앙 `FailOpenNotificationProvider`로 감싸고 예외를 삼킴 | global fail-open이며 application kind별 정책이 아님 |
|
||||
| unbound route | `AdapterDisabledException` fail-fast | `NotificationAdapterTest`; disabled sentinel은 없음 |
|
||||
| email seam | `google-email` + `GoogleEmailClient` interface | module 안 production client/SDK/credential/protocol 구현 0 |
|
||||
| Slack seam | `slack-webhook` + `SlackClient` interface | module 안 production client/SDK/credential/protocol 구현 0 |
|
||||
| configuration | code는 `app.notification.routes.*`와 provider별 `*.enabled`를 읽음 | checked-in `application.yml`/env registry의 `app.notification.{slack,email}.provider` selector와 drift |
|
||||
| production consumer | feature/application production consumer 0 | main source에는 application contract 선언, adapter 구현과 bootstrap composition만 존재 |
|
||||
| evidence grade | local fake/contract baseline | real provider, durability, callback, security, load evidence 0; 모든 seam `R0 legacy` |
|
||||
|
||||
selector drift는 이 기준선의 일부다. Task 1에서는 고치지 않는다. canonical graph가 준비되고
|
||||
cutover evidence가 생기기 전까지 기존 key를 새 의미로 재사용하거나 legacy class에 production
|
||||
동작을 추가하지 않는다.
|
||||
|
||||
## 모듈 개요
|
||||
|
||||
@@ -32,3 +53,26 @@ client 는 포킹 프로젝트가 채우는 seam 이다.
|
||||
`channel()`+`providerId()` 로 키잉된 `NotificationProvider` 빈으로 기여한다(예:
|
||||
`GoogleEmailProvider`, `SlackWebhookProvider`). `GoogleEmailClient`/`SlackClient` 는 포크가
|
||||
구현하는 seam 이며 실패는 데코레이터가 fail-open 처리한다.
|
||||
|
||||
## Wave G deletion inventory
|
||||
|
||||
다음 surface는 canonical-only cutover와 retained evidence 검증이 끝난 뒤 한 묶음으로 제거한다.
|
||||
그 전에는 동작을 확장하지 않고 R0 회귀 기준선으로만 유지한다.
|
||||
|
||||
- application R0 contract:
|
||||
`Channel`, `Notification`, raw `NotificationPort`
|
||||
- router/decorator SPI:
|
||||
`NotificationConfig`, `NotificationRoutesSettings`, `RoutingNotifier`,
|
||||
`NotificationProvider`, `FailOpenNotificationProvider`
|
||||
- fake-only Google email seam:
|
||||
`GoogleEmailClient`, `GoogleEmailProvider`, `GoogleEmailNotificationAdapterConfig`
|
||||
- fake-only Slack webhook seam:
|
||||
`SlackClient`, `SlackWebhookProvider`, `SlackNotificationAdapterConfig`
|
||||
- legacy configuration/tests:
|
||||
`app.notification.routes.*`, `app.notification.google-email.enabled`,
|
||||
`app.notification.slack-webhook.enabled`, drifted
|
||||
`APP_NOTIFICATION_EMAIL_PROVIDER`/`APP_NOTIFICATION_SLACK_PROVIDER`,
|
||||
`NotificationAdapterTest`, `RoutingNotifierTest`와 bootstrap legacy gating cases
|
||||
|
||||
accepted 또는 indeterminate work를 inventory하지 않은 상태에서 이 목록을 삭제하거나 canonical
|
||||
provider로 자동 재전송하지 않는다.
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.catalog;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Fully resolved immutable route graph; only {@link NotificationBindingCompiler} can construct it.
|
||||
*/
|
||||
public final class CompiledNotificationBinding {
|
||||
|
||||
private final NotificationRouteDescriptor route;
|
||||
private final NotificationTemplateDescriptor template;
|
||||
private final List<CompiledTarget> targets;
|
||||
private final String bindingDigest;
|
||||
|
||||
CompiledNotificationBinding(
|
||||
NotificationRouteDescriptor route,
|
||||
NotificationTemplateDescriptor template,
|
||||
List<CompiledTarget> targets,
|
||||
String bindingDigest) {
|
||||
this.route = Objects.requireNonNull(route, "compiled notification route must be non-null");
|
||||
this.template =
|
||||
Objects.requireNonNull(template, "compiled notification template must be non-null");
|
||||
Objects.requireNonNull(targets, "compiled notification targets must be non-null");
|
||||
this.targets =
|
||||
targets.stream()
|
||||
.map(target -> Objects.requireNonNull(target, "compiled target must be non-null"))
|
||||
.sorted(Comparator.comparing(target -> target.target().targetId()))
|
||||
.toList();
|
||||
if (this.targets.size() != route.targets().size()) {
|
||||
throw new IllegalArgumentException(
|
||||
"compiled notification target count must match route target count");
|
||||
}
|
||||
this.bindingDigest =
|
||||
NotificationCatalogException.requireDigest(
|
||||
"compiled notification binding digest", bindingDigest);
|
||||
}
|
||||
|
||||
public NotificationRouteDescriptor route() {
|
||||
return route;
|
||||
}
|
||||
|
||||
public NotificationTemplateDescriptor template() {
|
||||
return template;
|
||||
}
|
||||
|
||||
public List<CompiledTarget> targets() {
|
||||
return targets;
|
||||
}
|
||||
|
||||
public String bindingDigest() {
|
||||
return bindingDigest;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
return this == other
|
||||
|| (other instanceof CompiledNotificationBinding that
|
||||
&& route.equals(that.route)
|
||||
&& template.equals(that.template)
|
||||
&& targets.equals(that.targets)
|
||||
&& bindingDigest.equals(that.bindingDigest));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(route, template, targets, bindingDigest);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "CompiledNotificationBinding[route="
|
||||
+ route.routeId().value()
|
||||
+ ", routeRevision="
|
||||
+ route.routeRevision()
|
||||
+ ", template="
|
||||
+ template.templateRef().templateId()
|
||||
+ "-v"
|
||||
+ template.templateRef().version()
|
||||
+ ", targets=<redacted>, bindingDigest="
|
||||
+ bindingDigest
|
||||
+ "]";
|
||||
}
|
||||
|
||||
/** Non-forgeable resolved target exposed read-only to provider protocol implementations. */
|
||||
public static final class CompiledTarget {
|
||||
|
||||
private final NotificationRouteDescriptor.Target target;
|
||||
private final NotificationProviderRuntimeProfile runtimeProfile;
|
||||
private final NotificationProviderDescriptor provider;
|
||||
private final NotificationProviderCapabilityCard capabilityCard;
|
||||
|
||||
CompiledTarget(
|
||||
NotificationRouteDescriptor.Target target,
|
||||
NotificationProviderRuntimeProfile runtimeProfile,
|
||||
NotificationProviderDescriptor provider,
|
||||
NotificationProviderCapabilityCard capabilityCard) {
|
||||
this.target = Objects.requireNonNull(target, "compiled route target must be non-null");
|
||||
this.runtimeProfile =
|
||||
Objects.requireNonNull(runtimeProfile, "compiled runtime profile must be non-null");
|
||||
this.provider = Objects.requireNonNull(provider, "compiled provider must be non-null");
|
||||
this.capabilityCard =
|
||||
Objects.requireNonNull(capabilityCard, "compiled capability card must be non-null");
|
||||
}
|
||||
|
||||
public NotificationRouteDescriptor.Target target() {
|
||||
return target;
|
||||
}
|
||||
|
||||
public NotificationProviderRuntimeProfile runtimeProfile() {
|
||||
return runtimeProfile;
|
||||
}
|
||||
|
||||
public NotificationProviderDescriptor provider() {
|
||||
return provider;
|
||||
}
|
||||
|
||||
public NotificationProviderCapabilityCard capabilityCard() {
|
||||
return capabilityCard;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
return this == other
|
||||
|| (other instanceof CompiledTarget that
|
||||
&& target.equals(that.target)
|
||||
&& runtimeProfile.equals(that.runtimeProfile)
|
||||
&& provider.equals(that.provider)
|
||||
&& capabilityCard.equals(that.capabilityCard));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(target, runtimeProfile, provider, capabilityCard);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "CompiledTarget[targetId="
|
||||
+ target.targetId()
|
||||
+ ", runtimeProfileId="
|
||||
+ runtimeProfile.profileId()
|
||||
+ ", providerId="
|
||||
+ provider.providerId()
|
||||
+ ", capabilityCardId="
|
||||
+ capabilityCard.cardId()
|
||||
+ ", sensitiveReferences=<redacted>]";
|
||||
}
|
||||
}
|
||||
}
|
||||
+516
@@ -0,0 +1,516 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.catalog;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationRouteId;
|
||||
import dev.caskeleton.application.notification.NotificationTemplateRef;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Pure deterministic compiler over explicit provider, template, route and runtime-profile inputs.
|
||||
*/
|
||||
public final class NotificationBindingCompiler {
|
||||
|
||||
private static final Set<String> INITIAL_CARD_IDS =
|
||||
Set.of(
|
||||
"slack-web-api-inline-single-local-v1",
|
||||
"slack-web-api-durable-single-local-v1",
|
||||
"aws-ses-v2-durable-single-local-sns-v1");
|
||||
private static final Map<String, String> APPROVED_CARD_PROVIDERS =
|
||||
Map.of(
|
||||
"slack-web-api-inline-single-local-v1",
|
||||
"slack-web-api",
|
||||
"slack-web-api-durable-single-local-v1",
|
||||
"slack-web-api",
|
||||
"aws-ses-v2-durable-single-local-sns-v1",
|
||||
"aws-ses-v2");
|
||||
|
||||
private final List<NotificationProviderDescriptor> providers;
|
||||
private final List<NotificationTemplateDescriptor> templates;
|
||||
private final List<NotificationRouteDescriptor> routes;
|
||||
private final List<NotificationProviderRuntimeProfile> runtimeProfiles;
|
||||
private final Map<NotificationRouteId, Integer> activeRouteRevisions;
|
||||
|
||||
public NotificationBindingCompiler(
|
||||
List<NotificationProviderDescriptor> providers,
|
||||
List<NotificationTemplateDescriptor> templates,
|
||||
List<NotificationRouteDescriptor> routes,
|
||||
List<NotificationProviderRuntimeProfile> runtimeProfiles) {
|
||||
this(providers, templates, routes, runtimeProfiles, singletonActiveRevisions(routes));
|
||||
}
|
||||
|
||||
public NotificationBindingCompiler(
|
||||
List<NotificationProviderDescriptor> providers,
|
||||
List<NotificationTemplateDescriptor> templates,
|
||||
List<NotificationRouteDescriptor> routes,
|
||||
List<NotificationProviderRuntimeProfile> runtimeProfiles,
|
||||
Map<NotificationRouteId, Integer> activeRouteRevisions) {
|
||||
this.providers = immutableCopy("providers", providers);
|
||||
this.templates = immutableCopy("templates", templates);
|
||||
this.routes = immutableCopy("routes", routes);
|
||||
this.runtimeProfiles = immutableCopy("runtime profiles", runtimeProfiles);
|
||||
this.activeRouteRevisions =
|
||||
Map.copyOf(
|
||||
Objects.requireNonNull(
|
||||
activeRouteRevisions, "active notification route revisions must be non-null"));
|
||||
}
|
||||
|
||||
public CompiledGraph compile() {
|
||||
Map<String, NotificationProviderDescriptor> providerIndex =
|
||||
uniqueIndex(providers, NotificationProviderDescriptor::providerId, "provider");
|
||||
Map<NotificationTemplateRef, NotificationTemplateDescriptor> templateIndex =
|
||||
uniqueIndex(templates, NotificationTemplateDescriptor::templateRef, "template");
|
||||
Map<String, NotificationProviderRuntimeProfile> profileIndex =
|
||||
uniqueIndex(
|
||||
runtimeProfiles, NotificationProviderRuntimeProfile::profileId, "runtime profile");
|
||||
uniqueIndex(
|
||||
routes, route -> route.routeId().value() + "#" + route.routeRevision(), "route revision");
|
||||
validateActiveRevisions(routes, activeRouteRevisions);
|
||||
validateRegisteredCards(providerIndex.values());
|
||||
|
||||
List<CompiledNotificationBinding> bindings =
|
||||
routes.stream()
|
||||
.sorted(
|
||||
Comparator.comparing((NotificationRouteDescriptor route) -> route.routeId().value())
|
||||
.thenComparingInt(NotificationRouteDescriptor::routeRevision))
|
||||
.map(route -> compileRoute(route, providerIndex, templateIndex, profileIndex))
|
||||
.toList();
|
||||
if (bindings.isEmpty() || bindings.size() > 100) {
|
||||
throw new NotificationCatalogException(
|
||||
"notification binding graph must contain 1..100 routes");
|
||||
}
|
||||
return new CompiledGraph(
|
||||
bindings, activeRouteRevisions, manifestDigest(bindings, activeRouteRevisions));
|
||||
}
|
||||
|
||||
private static CompiledNotificationBinding compileRoute(
|
||||
NotificationRouteDescriptor route,
|
||||
Map<String, NotificationProviderDescriptor> providers,
|
||||
Map<NotificationTemplateRef, NotificationTemplateDescriptor> templates,
|
||||
Map<String, NotificationProviderRuntimeProfile> profiles) {
|
||||
NotificationTemplateDescriptor template = templates.get(route.templateRef());
|
||||
if (template == null) {
|
||||
throw new NotificationCatalogException(
|
||||
"unknown template for route " + route.routeId().value());
|
||||
}
|
||||
if (template.channel() != route.channel()) {
|
||||
throw new NotificationCatalogException(
|
||||
"template channel does not match route " + route.routeId().value());
|
||||
}
|
||||
validateRouteShape(route);
|
||||
|
||||
List<CompiledNotificationBinding.CompiledTarget> targets = new ArrayList<>();
|
||||
for (NotificationRouteDescriptor.Target target : route.targets()) {
|
||||
NotificationProviderRuntimeProfile profile = profiles.get(target.runtimeProfileId());
|
||||
if (profile == null) {
|
||||
throw new NotificationCatalogException(
|
||||
"unknown runtime profile for target " + target.targetId());
|
||||
}
|
||||
NotificationProviderDescriptor provider = providers.get(profile.providerId());
|
||||
if (provider == null) {
|
||||
throw new NotificationCatalogException(
|
||||
"unknown provider for runtime profile " + profile.profileId());
|
||||
}
|
||||
if (provider.legacyFailOpen()) {
|
||||
throw new NotificationCatalogException(
|
||||
"legacy fail-open provider cannot participate in a canonical binding");
|
||||
}
|
||||
NotificationProviderCapabilityCard card =
|
||||
provider.capabilityCards().stream()
|
||||
.filter(candidate -> candidate.cardId().equals(profile.capabilityCardId()))
|
||||
.findFirst()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new NotificationCatalogException(
|
||||
"unknown capability card for runtime profile " + profile.profileId()));
|
||||
validateCompatibility(route, template, provider, card);
|
||||
targets.add(new CompiledNotificationBinding.CompiledTarget(target, profile, provider, card));
|
||||
}
|
||||
String bindingDigest = bindingDigest(route, template, targets);
|
||||
return new CompiledNotificationBinding(route, template, targets, bindingDigest);
|
||||
}
|
||||
|
||||
private static void validateRouteShape(NotificationRouteDescriptor route) {
|
||||
if (route.routeStrategy()
|
||||
!= dev.caskeleton.application.notification.NotificationRouteStrategy.SINGLE) {
|
||||
throw new NotificationCatalogException(
|
||||
"initial notification catalog supports SINGLE route strategy only");
|
||||
}
|
||||
if (route.targets().size() != route.maximumTargets()) {
|
||||
throw new NotificationCatalogException(
|
||||
"route target count must equal its maximum target bound");
|
||||
}
|
||||
long worstCaseCalls =
|
||||
Math.addExact(
|
||||
Math.multiplyExact(
|
||||
(long) route.maximumTargets(), (long) route.maximumPhysicalAttempts()),
|
||||
route.maximumReconcileCalls());
|
||||
if (worstCaseCalls > route.maximumTotalProviderCalls()) {
|
||||
throw new NotificationCatalogException(
|
||||
"route amplification exceeds maximum total provider calls");
|
||||
}
|
||||
switch (route.routeStrategy()) {
|
||||
case SINGLE -> {
|
||||
if (route.maximumTargets() != 1
|
||||
|| route.maximumFallbackActivations() != 0
|
||||
|| route.targets().stream().anyMatch(target -> target.fallbackTargetId().isPresent())) {
|
||||
throw new NotificationCatalogException(
|
||||
"SINGLE route requires one target and no fallback");
|
||||
}
|
||||
}
|
||||
case FAN_OUT_ALL -> {
|
||||
if (route.maximumFallbackActivations() != 0
|
||||
|| route.targets().stream().anyMatch(target -> target.fallbackTargetId().isPresent())) {
|
||||
throw new NotificationCatalogException("FAN_OUT_ALL route cannot define fallback");
|
||||
}
|
||||
}
|
||||
case ORDERED_FALLBACK -> {
|
||||
if (route.maximumTargets() < 2
|
||||
|| route.maximumFallbackActivations() < 1
|
||||
|| route.maximumFallbackActivations() > route.maximumTargets() - 1) {
|
||||
throw new NotificationCatalogException("ORDERED_FALLBACK route has invalid bounds");
|
||||
}
|
||||
}
|
||||
default ->
|
||||
throw new NotificationCatalogException(
|
||||
"unknown notification route strategy: " + route.routeStrategy());
|
||||
}
|
||||
validateFallbackGraph(route);
|
||||
}
|
||||
|
||||
private static void validateFallbackGraph(NotificationRouteDescriptor route) {
|
||||
Map<String, String> edges = new HashMap<>();
|
||||
Set<String> targets =
|
||||
route.targets().stream()
|
||||
.map(NotificationRouteDescriptor.Target::targetId)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
route
|
||||
.targets()
|
||||
.forEach(
|
||||
target ->
|
||||
target
|
||||
.fallbackTargetId()
|
||||
.ifPresent(
|
||||
fallback -> {
|
||||
if (!targets.contains(fallback)) {
|
||||
throw new NotificationCatalogException(
|
||||
"fallback references unknown target");
|
||||
}
|
||||
edges.put(target.targetId(), fallback);
|
||||
}));
|
||||
for (String start : targets) {
|
||||
Set<String> visited = new HashSet<>();
|
||||
String current = start;
|
||||
while (current != null) {
|
||||
if (!visited.add(current)) {
|
||||
throw new NotificationCatalogException("cyclic notification fallback graph");
|
||||
}
|
||||
current = edges.get(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateCompatibility(
|
||||
NotificationRouteDescriptor route,
|
||||
NotificationTemplateDescriptor template,
|
||||
NotificationProviderDescriptor provider,
|
||||
NotificationProviderCapabilityCard card) {
|
||||
if (provider.channel() != route.channel() || card.channel() != route.channel()) {
|
||||
throw new NotificationCatalogException(
|
||||
"provider channel does not match route " + route.routeId().value());
|
||||
}
|
||||
if (card.mode() != route.mode()) {
|
||||
throw new NotificationCatalogException(
|
||||
"provider mode does not match route " + route.routeId().value());
|
||||
}
|
||||
if (card.routeStrategy() != route.routeStrategy()) {
|
||||
throw new NotificationCatalogException(
|
||||
"provider route strategy does not match route " + route.routeId().value());
|
||||
}
|
||||
if (route.maximumTargets() > card.maximumTargets()) {
|
||||
throw new NotificationCatalogException("route target bound exceeds provider capability");
|
||||
}
|
||||
if (route.receiptRequired() && !card.receiptSupported()) {
|
||||
throw new NotificationCatalogException(
|
||||
"receipt-required route uses provider without receipt capability");
|
||||
}
|
||||
if (route.maximumReconcileCalls() > card.maximumReconcileCalls()
|
||||
|| (route.maximumReconcileCalls() > 0 && !card.reconciliationSupported())) {
|
||||
throw new NotificationCatalogException(
|
||||
"route reconciliation bound exceeds provider capability");
|
||||
}
|
||||
if (route.maximumPhysicalAttempts() > card.maximumPhysicalAttempts()) {
|
||||
throw new NotificationCatalogException("route attempt bound exceeds provider capability");
|
||||
}
|
||||
if (route.maximumTotalProviderCalls() > card.maximumTotalProviderCalls()) {
|
||||
throw new NotificationCatalogException(
|
||||
"route amplification bound exceeds provider capability");
|
||||
}
|
||||
if (template.maximumRenderedBytes() > card.maximumPayloadBytes()) {
|
||||
throw new NotificationCatalogException("template payload bound exceeds provider capability");
|
||||
}
|
||||
if (route.fallbackAfterIndeterminate() && card.terminalIndeterminatePossible()) {
|
||||
throw new NotificationCatalogException(
|
||||
"fallback after an indeterminate submission is unsafe");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateRegisteredCards(
|
||||
java.util.Collection<NotificationProviderDescriptor> providers) {
|
||||
Set<String> seen = new HashSet<>();
|
||||
providers.forEach(
|
||||
provider ->
|
||||
provider
|
||||
.capabilityCards()
|
||||
.forEach(
|
||||
card -> {
|
||||
if (!INITIAL_CARD_IDS.contains(card.cardId())) {
|
||||
throw new NotificationCatalogException(
|
||||
"unknown provider capability card: " + card.cardId());
|
||||
}
|
||||
if (!provider
|
||||
.providerId()
|
||||
.equals(APPROVED_CARD_PROVIDERS.get(card.cardId()))) {
|
||||
throw new NotificationCatalogException(
|
||||
"provider capability card is bound to a different provider family");
|
||||
}
|
||||
NotificationProviderCapabilityCard approved =
|
||||
NotificationProviderCapabilityCard.initial(card.cardId());
|
||||
if (!approved.equals(card)) {
|
||||
throw new NotificationCatalogException(
|
||||
"provider capability card differs from approved definition: "
|
||||
+ card.cardId());
|
||||
}
|
||||
if (!seen.add(card.cardId())) {
|
||||
throw new NotificationCatalogException(
|
||||
"duplicate provider capability card: " + card.cardId());
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private static String bindingDigest(
|
||||
NotificationRouteDescriptor route,
|
||||
NotificationTemplateDescriptor template,
|
||||
List<CompiledNotificationBinding.CompiledTarget> targets) {
|
||||
MessageDigest digest = NotificationCatalogException.sha256();
|
||||
updateRoute(digest, route);
|
||||
NotificationCatalogException.update(digest, template.templateRef().templateId());
|
||||
NotificationCatalogException.update(digest, template.templateRef().version());
|
||||
NotificationCatalogException.update(digest, template.rendererRevision());
|
||||
NotificationCatalogException.update(digest, template.checksum());
|
||||
template.supportedLocales().stream()
|
||||
.map(java.util.Locale::toLanguageTag)
|
||||
.sorted()
|
||||
.forEach(locale -> NotificationCatalogException.update(digest, locale));
|
||||
NotificationCatalogException.update(digest, template.fallbackLocale().toLanguageTag());
|
||||
template.parameterNames().stream()
|
||||
.sorted()
|
||||
.forEach(parameter -> NotificationCatalogException.update(digest, parameter));
|
||||
NotificationCatalogException.update(digest, template.maximumRenderedBytes());
|
||||
targets.stream()
|
||||
.sorted(Comparator.comparing(target -> target.target().targetId()))
|
||||
.forEach(
|
||||
target -> {
|
||||
NotificationCatalogException.update(digest, target.target().targetId());
|
||||
NotificationCatalogException.update(
|
||||
digest, target.target().fallbackTargetId().orElse(""));
|
||||
NotificationCatalogException.update(digest, target.runtimeProfile().profileId());
|
||||
NotificationCatalogException.update(
|
||||
digest, target.runtimeProfile().bindingRevision());
|
||||
NotificationCatalogException.update(
|
||||
digest, target.runtimeProfile().credentialGeneration());
|
||||
NotificationCatalogException.update(
|
||||
digest, target.runtimeProfile().credentialReference());
|
||||
NotificationCatalogException.update(
|
||||
digest, target.runtimeProfile().destinationReference());
|
||||
NotificationCatalogException.update(digest, target.provider().providerId());
|
||||
NotificationCatalogException.update(digest, target.capabilityCard().cardId());
|
||||
});
|
||||
return NotificationCatalogException.finish(digest);
|
||||
}
|
||||
|
||||
private static String manifestDigest(
|
||||
List<CompiledNotificationBinding> bindings,
|
||||
Map<NotificationRouteId, Integer> activeRouteRevisions) {
|
||||
MessageDigest digest = NotificationCatalogException.sha256();
|
||||
bindings.forEach(
|
||||
binding -> {
|
||||
NotificationCatalogException.update(digest, binding.route().routeId().value());
|
||||
NotificationCatalogException.update(digest, binding.route().routeRevision());
|
||||
NotificationCatalogException.update(digest, binding.bindingDigest());
|
||||
});
|
||||
activeRouteRevisions.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey(Comparator.comparing(NotificationRouteId::value)))
|
||||
.forEach(
|
||||
entry -> {
|
||||
NotificationCatalogException.update(digest, "active");
|
||||
NotificationCatalogException.update(digest, entry.getKey().value());
|
||||
NotificationCatalogException.update(digest, entry.getValue());
|
||||
});
|
||||
return NotificationCatalogException.finish(digest);
|
||||
}
|
||||
|
||||
private static Map<NotificationRouteId, Integer> singletonActiveRevisions(
|
||||
List<NotificationRouteDescriptor> routes) {
|
||||
Objects.requireNonNull(routes, "notification routes must be non-null");
|
||||
Map<NotificationRouteId, Integer> active = new HashMap<>();
|
||||
routes.forEach(
|
||||
route -> {
|
||||
Objects.requireNonNull(route, "notification route entry is null");
|
||||
if (active.putIfAbsent(route.routeId(), route.routeRevision()) != null) {
|
||||
throw new NotificationCatalogException(
|
||||
"multiple retained route revisions require an explicit active revision map");
|
||||
}
|
||||
});
|
||||
return Map.copyOf(active);
|
||||
}
|
||||
|
||||
private static void validateActiveRevisions(
|
||||
List<NotificationRouteDescriptor> routes,
|
||||
Map<NotificationRouteId, Integer> activeRouteRevisions) {
|
||||
Set<NotificationRouteId> routeIds =
|
||||
routes.stream()
|
||||
.map(NotificationRouteDescriptor::routeId)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
if (!activeRouteRevisions.keySet().equals(routeIds)) {
|
||||
throw new NotificationCatalogException(
|
||||
"active route revision map must exactly match retained route IDs");
|
||||
}
|
||||
activeRouteRevisions.forEach(
|
||||
(routeId, revision) -> {
|
||||
Objects.requireNonNull(routeId, "active route ID must be non-null");
|
||||
Objects.requireNonNull(revision, "active route revision must be non-null");
|
||||
if (routes.stream()
|
||||
.noneMatch(
|
||||
route -> route.routeId().equals(routeId) && route.routeRevision() == revision)) {
|
||||
throw new NotificationCatalogException(
|
||||
"active route revision does not exist in retained bindings");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void updateRoute(MessageDigest digest, NotificationRouteDescriptor route) {
|
||||
NotificationCatalogException.update(digest, route.routeId().value());
|
||||
NotificationCatalogException.update(digest, route.routeRevision());
|
||||
NotificationCatalogException.update(digest, route.channel().name());
|
||||
NotificationCatalogException.update(digest, route.mode().name());
|
||||
NotificationCatalogException.update(digest, route.admissionClass().name());
|
||||
NotificationCatalogException.update(digest, route.routeStrategy().name());
|
||||
NotificationCatalogException.update(digest, route.receiptRequired());
|
||||
NotificationCatalogException.update(digest, route.fallbackAfterIndeterminate());
|
||||
NotificationCatalogException.update(digest, route.maximumTargets());
|
||||
NotificationCatalogException.update(digest, route.maximumPhysicalAttempts());
|
||||
NotificationCatalogException.update(digest, route.maximumFallbackActivations());
|
||||
NotificationCatalogException.update(digest, route.maximumReconcileCalls());
|
||||
NotificationCatalogException.update(digest, route.maximumTotalProviderCalls());
|
||||
NotificationCatalogException.update(digest, route.perAttemptDeadline().toNanos());
|
||||
}
|
||||
|
||||
private static <T> List<T> immutableCopy(String label, List<T> source) {
|
||||
Objects.requireNonNull(source, "notification " + label + " must be non-null");
|
||||
return source.stream()
|
||||
.map(value -> Objects.requireNonNull(value, "notification " + label + " entry is null"))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static <K, V> Map<K, V> uniqueIndex(List<V> values, Function<V, K> key, String label) {
|
||||
Map<K, V> index = new HashMap<>();
|
||||
values.forEach(
|
||||
value -> {
|
||||
K itemKey = key.apply(value);
|
||||
if (index.putIfAbsent(itemKey, value) != null) {
|
||||
throw new NotificationCatalogException("duplicate " + label + ": " + itemKey);
|
||||
}
|
||||
});
|
||||
return Map.copyOf(index);
|
||||
}
|
||||
|
||||
/** Sorted immutable result and digest suitable for release evidence. */
|
||||
public static final class CompiledGraph {
|
||||
|
||||
private final List<CompiledNotificationBinding> bindings;
|
||||
private final Map<NotificationRouteId, Integer> activeRouteRevisions;
|
||||
private final String manifestDigest;
|
||||
|
||||
private CompiledGraph(
|
||||
List<CompiledNotificationBinding> bindings,
|
||||
Map<NotificationRouteId, Integer> activeRouteRevisions,
|
||||
String manifestDigest) {
|
||||
Objects.requireNonNull(bindings, "compiled notification bindings must be non-null");
|
||||
this.bindings =
|
||||
bindings.stream()
|
||||
.map(binding -> Objects.requireNonNull(binding, "compiled binding must be non-null"))
|
||||
.sorted(
|
||||
Comparator.comparing(
|
||||
(CompiledNotificationBinding binding) ->
|
||||
binding.route().routeId().value())
|
||||
.thenComparingInt(binding -> binding.route().routeRevision()))
|
||||
.toList();
|
||||
this.activeRouteRevisions =
|
||||
Map.copyOf(
|
||||
Objects.requireNonNull(
|
||||
activeRouteRevisions, "active route revisions must be non-null"));
|
||||
this.manifestDigest =
|
||||
NotificationCatalogException.requireDigest(
|
||||
"notification graph manifest digest", manifestDigest);
|
||||
}
|
||||
|
||||
public List<CompiledNotificationBinding> bindings() {
|
||||
return bindings;
|
||||
}
|
||||
|
||||
public Map<NotificationRouteId, Integer> activeRouteRevisions() {
|
||||
return activeRouteRevisions;
|
||||
}
|
||||
|
||||
public String manifestDigest() {
|
||||
return manifestDigest;
|
||||
}
|
||||
|
||||
public CompiledNotificationBinding activeBinding(NotificationRouteId routeId) {
|
||||
Objects.requireNonNull(routeId, "active notification route ID must be non-null");
|
||||
Integer revision = activeRouteRevisions.get(routeId);
|
||||
if (revision == null) {
|
||||
return null;
|
||||
}
|
||||
return bindings.stream()
|
||||
.filter(binding -> binding.route().routeId().equals(routeId))
|
||||
.filter(binding -> binding.route().routeRevision() == revision)
|
||||
.findFirst()
|
||||
.orElseThrow(
|
||||
() -> new IllegalStateException("active notification binding is not retained"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
return this == other
|
||||
|| (other instanceof CompiledGraph that
|
||||
&& bindings.equals(that.bindings)
|
||||
&& activeRouteRevisions.equals(that.activeRouteRevisions)
|
||||
&& manifestDigest.equals(that.manifestDigest));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(bindings, activeRouteRevisions, manifestDigest);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "CompiledGraph[bindingCount="
|
||||
+ bindings.size()
|
||||
+ ", activeRouteCount="
|
||||
+ activeRouteRevisions.size()
|
||||
+ ", manifestDigest="
|
||||
+ manifestDigest
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.catalog;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationCanonicalWriterRouteSet;
|
||||
import dev.caskeleton.application.notification.NotificationRouteId;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Retained key-only source of truth derived only from the compiled binding graph. */
|
||||
public final class NotificationCanonicalRouteCatalog {
|
||||
|
||||
private final List<RouteKey> routes;
|
||||
|
||||
private NotificationCanonicalRouteCatalog(List<RouteKey> routes) {
|
||||
Objects.requireNonNull(routes, "canonical notification routes must be non-null");
|
||||
this.routes =
|
||||
routes.stream()
|
||||
.map(route -> Objects.requireNonNull(route, "canonical route key must be non-null"))
|
||||
.sorted(Comparator.naturalOrder())
|
||||
.toList();
|
||||
if (this.routes.isEmpty() || this.routes.size() > 100) {
|
||||
throw new IllegalArgumentException("canonical route catalog must contain 1..100 routes");
|
||||
}
|
||||
if (new HashSet<>(this.routes).size() != this.routes.size()) {
|
||||
throw new NotificationCatalogException("duplicate canonical route key");
|
||||
}
|
||||
if (this.routes.stream().map(RouteKey::routeId).distinct().count() != this.routes.size()) {
|
||||
throw new NotificationCatalogException("duplicate canonical route ID has multiple revisions");
|
||||
}
|
||||
}
|
||||
|
||||
public static NotificationCanonicalRouteCatalog fromCompiledGraph(
|
||||
NotificationBindingCompiler.CompiledGraph graph) {
|
||||
Objects.requireNonNull(graph, "compiled notification graph must be non-null");
|
||||
return new NotificationCanonicalRouteCatalog(
|
||||
graph.activeRouteRevisions().entrySet().stream()
|
||||
.map(entry -> new RouteKey(entry.getKey(), entry.getValue()))
|
||||
.toList());
|
||||
}
|
||||
|
||||
static NotificationCanonicalRouteCatalog fromRoutes(List<NotificationRouteDescriptor> routes) {
|
||||
Objects.requireNonNull(routes, "notification route descriptors must be non-null");
|
||||
return new NotificationCanonicalRouteCatalog(
|
||||
routes.stream()
|
||||
.map(
|
||||
route -> {
|
||||
Objects.requireNonNull(route, "notification route descriptor must be non-null");
|
||||
return new RouteKey(route.routeId(), route.routeRevision());
|
||||
})
|
||||
.toList());
|
||||
}
|
||||
|
||||
public List<RouteKey> routes() {
|
||||
return routes;
|
||||
}
|
||||
|
||||
public NotificationCanonicalWriterRouteSet toApplication(
|
||||
Map<RouteKey, Long> predecessorGenerations) {
|
||||
Objects.requireNonNull(
|
||||
predecessorGenerations, "canonical predecessor generations must be non-null");
|
||||
if (!predecessorGenerations.keySet().equals(Set.copyOf(routes))) {
|
||||
throw new NotificationCatalogException(
|
||||
"canonical generation config must exactly match route catalog keys");
|
||||
}
|
||||
return new NotificationCanonicalWriterRouteSet(
|
||||
routes.stream()
|
||||
.map(
|
||||
route ->
|
||||
new NotificationCanonicalWriterRouteSet.RouteRevision(
|
||||
route.routeId(),
|
||||
route.routeRevision(),
|
||||
Objects.requireNonNull(
|
||||
predecessorGenerations.get(route),
|
||||
"predecessor generation must be non-null")))
|
||||
.toList());
|
||||
}
|
||||
|
||||
public String digest() {
|
||||
MessageDigest digest = NotificationCatalogException.sha256();
|
||||
routes.forEach(
|
||||
route -> {
|
||||
NotificationCatalogException.update(digest, route.routeId().value());
|
||||
NotificationCatalogException.update(digest, route.routeRevision());
|
||||
});
|
||||
return NotificationCatalogException.finish(digest);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
return this == other
|
||||
|| (other instanceof NotificationCanonicalRouteCatalog that && routes.equals(that.routes));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return routes.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "NotificationCanonicalRouteCatalog[routes=" + routes + "]";
|
||||
}
|
||||
|
||||
public record RouteKey(NotificationRouteId routeId, int routeRevision)
|
||||
implements Comparable<RouteKey> {
|
||||
|
||||
public RouteKey {
|
||||
Objects.requireNonNull(routeId, "canonical notification route ID must be non-null");
|
||||
if (routeRevision < 1 || routeRevision > 1_000_000) {
|
||||
throw new IllegalArgumentException("canonical route revision must be in 1..1000000");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(RouteKey other) {
|
||||
int routeOrder = routeId.value().compareTo(other.routeId.value());
|
||||
return routeOrder != 0 ? routeOrder : Integer.compare(routeRevision, other.routeRevision);
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.catalog;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/** Fail-closed error for an invalid checked-in notification catalog. */
|
||||
public final class NotificationCatalogException extends RuntimeException {
|
||||
|
||||
public NotificationCatalogException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
static String requireSlug(String field, String value) {
|
||||
if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) {
|
||||
throw new IllegalArgumentException(field + " must match [a-z][a-z0-9.-]{0,62}");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static String requireOpaque(String field, String value) {
|
||||
if (value == null || !value.matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")) {
|
||||
throw new IllegalArgumentException(
|
||||
field + " must contain 1..128 opaque identifier characters");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static String requireDigest(String field, String value) {
|
||||
if (value == null || !value.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException(field + " must be a lowercase SHA-256 digest");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException unavailable) {
|
||||
throw new IllegalStateException(
|
||||
"SHA-256 must be available on every Java runtime", unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
static void update(MessageDigest digest, String value) {
|
||||
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(encoded.length).array());
|
||||
digest.update(encoded);
|
||||
}
|
||||
|
||||
static void update(MessageDigest digest, int value) {
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value).array());
|
||||
}
|
||||
|
||||
static void update(MessageDigest digest, long value) {
|
||||
digest.update(ByteBuffer.allocate(Long.BYTES).putLong(value).array());
|
||||
}
|
||||
|
||||
static void update(MessageDigest digest, boolean value) {
|
||||
digest.update((byte) (value ? 1 : 0));
|
||||
}
|
||||
|
||||
static String finish(MessageDigest digest) {
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.catalog;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationCanonicalWriterRouteSet;
|
||||
import dev.caskeleton.application.notification.NotificationWriterRouteSet;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** PRE-only legacy proof metadata decorating exactly the retained canonical route keys. */
|
||||
public record NotificationCutoverRouteCatalog(
|
||||
NotificationCanonicalRouteCatalog canonical, List<CutoverRoute> routes) {
|
||||
|
||||
public NotificationCutoverRouteCatalog {
|
||||
Objects.requireNonNull(canonical, "canonical route catalog must be non-null");
|
||||
Objects.requireNonNull(routes, "cutover route catalog must be non-null");
|
||||
routes =
|
||||
routes.stream()
|
||||
.map(route -> Objects.requireNonNull(route, "cutover route must be non-null"))
|
||||
.sorted(Comparator.comparing(CutoverRoute::route))
|
||||
.toList();
|
||||
if (!routes.stream().map(CutoverRoute::route).toList().equals(canonical.routes())) {
|
||||
throw new NotificationCatalogException(
|
||||
"cutover route keys must exactly equal canonical route keys");
|
||||
}
|
||||
List<String> aliases = routes.stream().flatMap(route -> route.legacyAlias().stream()).toList();
|
||||
if (new HashSet<>(aliases).size() != aliases.size()) {
|
||||
throw new NotificationCatalogException("cutover route contains duplicate legacy alias");
|
||||
}
|
||||
}
|
||||
|
||||
public NotificationWriterRouteSet toApplication(
|
||||
Map<NotificationCanonicalRouteCatalog.RouteKey, Long> predecessorGenerations) {
|
||||
NotificationCanonicalWriterRouteSet applicationCanonical =
|
||||
canonical.toApplication(predecessorGenerations);
|
||||
Map<
|
||||
NotificationCanonicalRouteCatalog.RouteKey,
|
||||
NotificationCanonicalWriterRouteSet.RouteRevision>
|
||||
routeIndex =
|
||||
applicationCanonical.routes().stream()
|
||||
.collect(
|
||||
java.util.stream.Collectors.toUnmodifiableMap(
|
||||
route ->
|
||||
new NotificationCanonicalRouteCatalog.RouteKey(
|
||||
route.routeId(), route.routeRevision()),
|
||||
route -> route));
|
||||
return new NotificationWriterRouteSet(
|
||||
applicationCanonical,
|
||||
routes.stream()
|
||||
.map(
|
||||
route ->
|
||||
new NotificationWriterRouteSet.RouteProfile(
|
||||
routeIndex.get(route.route()),
|
||||
route.legacyAlias(),
|
||||
route.transportProfiles().stream()
|
||||
.map(LegacyTransportProfile::toApplication)
|
||||
.toList()))
|
||||
.toList());
|
||||
}
|
||||
|
||||
public String digest() {
|
||||
MessageDigest digest = NotificationCatalogException.sha256();
|
||||
NotificationCatalogException.update(digest, canonical.digest());
|
||||
routes.forEach(
|
||||
route -> {
|
||||
NotificationCatalogException.update(digest, route.route().routeId().value());
|
||||
NotificationCatalogException.update(digest, route.route().routeRevision());
|
||||
NotificationCatalogException.update(digest, route.legacyAlias().orElse(""));
|
||||
route
|
||||
.transportProfiles()
|
||||
.forEach(
|
||||
profile -> {
|
||||
NotificationCatalogException.update(digest, profile.profileId());
|
||||
NotificationCatalogException.update(digest, profile.proofClass().name());
|
||||
NotificationCatalogException.update(digest, profile.evidenceRevision());
|
||||
NotificationCatalogException.update(digest, profile.activeAdmissionProfile());
|
||||
NotificationCatalogException.update(
|
||||
digest, profile.reviewedHardBoundEvidence());
|
||||
});
|
||||
});
|
||||
return NotificationCatalogException.finish(digest);
|
||||
}
|
||||
|
||||
public record CutoverRoute(
|
||||
NotificationCanonicalRouteCatalog.RouteKey route,
|
||||
Optional<String> legacyAlias,
|
||||
List<LegacyTransportProfile> transportProfiles) {
|
||||
|
||||
public CutoverRoute {
|
||||
Objects.requireNonNull(route, "cutover route key must be non-null");
|
||||
Objects.requireNonNull(legacyAlias, "legacy route alias container must be non-null");
|
||||
legacyAlias =
|
||||
legacyAlias.map(
|
||||
alias -> NotificationCatalogException.requireSlug("legacy route alias", alias));
|
||||
Objects.requireNonNull(
|
||||
transportProfiles, "legacy transport profile registry must be non-null");
|
||||
transportProfiles =
|
||||
transportProfiles.stream()
|
||||
.map(
|
||||
profile ->
|
||||
Objects.requireNonNull(profile, "legacy transport profile must be non-null"))
|
||||
.sorted(Comparator.comparing(LegacyTransportProfile::profileId))
|
||||
.toList();
|
||||
if (transportProfiles.isEmpty() || transportProfiles.size() > 8) {
|
||||
throw new IllegalArgumentException(
|
||||
"legacy transport profile registry must contain 1..8 entries");
|
||||
}
|
||||
if (new HashSet<>(transportProfiles.stream().map(LegacyTransportProfile::profileId).toList())
|
||||
.size()
|
||||
!= transportProfiles.size()) {
|
||||
throw new IllegalArgumentException(
|
||||
"legacy transport profile registry contains duplicate profile");
|
||||
}
|
||||
if (transportProfiles.stream().filter(LegacyTransportProfile::activeAdmissionProfile).count()
|
||||
!= 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"legacy transport registry requires exactly one active admission profile");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record LegacyTransportProfile(
|
||||
String profileId,
|
||||
NotificationWriterRouteSet.ProofClass proofClass,
|
||||
String evidenceRevision,
|
||||
boolean activeAdmissionProfile,
|
||||
boolean reviewedHardBoundEvidence) {
|
||||
|
||||
public LegacyTransportProfile {
|
||||
profileId =
|
||||
NotificationCatalogException.requireSlug("legacy transport profile ID", profileId);
|
||||
Objects.requireNonNull(proofClass, "legacy transport proof class must be non-null");
|
||||
evidenceRevision =
|
||||
NotificationCatalogException.requireSlug(
|
||||
"legacy transport evidence revision", evidenceRevision);
|
||||
if (proofClass == NotificationWriterRouteSet.ProofClass.HARD_BOUND_PROVEN
|
||||
&& !reviewedHardBoundEvidence) {
|
||||
throw new IllegalArgumentException(
|
||||
"HARD_BOUND_PROVEN requires reviewed integration evidence");
|
||||
}
|
||||
}
|
||||
|
||||
NotificationWriterRouteSet.TransportProfile toApplication() {
|
||||
return new NotificationWriterRouteSet.TransportProfile(
|
||||
profileId, proofClass, evidenceRevision, activeAdmissionProfile);
|
||||
}
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.catalog;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationFrozenPlan;
|
||||
import dev.caskeleton.application.notification.NotificationIntentDraft;
|
||||
import dev.caskeleton.application.notification.NotificationKindPolicy;
|
||||
import dev.caskeleton.application.notification.NotificationPlanPort;
|
||||
import dev.caskeleton.application.notification.NotificationPlanningResult;
|
||||
import dev.caskeleton.application.notification.NotificationReasonCode;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Converts a selected adapter-local binding to the application-owned immutable plan snapshot. */
|
||||
public final class NotificationPlanAdapter implements NotificationPlanPort {
|
||||
|
||||
private static final NotificationReasonCode ROUTE_UNAVAILABLE =
|
||||
new NotificationReasonCode("CATALOG_ROUTE_UNAVAILABLE");
|
||||
private static final NotificationReasonCode POLICY_MISMATCH =
|
||||
new NotificationReasonCode("CATALOG_POLICY_MISMATCH");
|
||||
private static final NotificationReasonCode PARAMETERS_MISMATCH =
|
||||
new NotificationReasonCode("CATALOG_PARAMETERS_MISMATCH");
|
||||
|
||||
private final NotificationBindingCompiler.CompiledGraph graph;
|
||||
|
||||
public NotificationPlanAdapter(NotificationBindingCompiler.CompiledGraph graph) {
|
||||
this.graph = Objects.requireNonNull(graph, "compiled notification graph must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationPlanningResult plan(NotificationIntentDraft draft) {
|
||||
Objects.requireNonNull(draft, "notification intent draft must be non-null");
|
||||
CompiledNotificationBinding binding = graph.activeBinding(draft.policy().routeId());
|
||||
if (binding == null) {
|
||||
return new NotificationPlanningResult.CapabilityUnavailable(ROUTE_UNAVAILABLE);
|
||||
}
|
||||
if (!matchesPolicy(binding.route(), draft.policy())) {
|
||||
return new NotificationPlanningResult.Rejected(POLICY_MISMATCH);
|
||||
}
|
||||
if (!binding.template().parameterNames().equals(draft.parameters().values().keySet())) {
|
||||
return new NotificationPlanningResult.Rejected(PARAMETERS_MISMATCH);
|
||||
}
|
||||
|
||||
Locale locale = binding.template().selectLocale(draft.requestedLocale());
|
||||
List<NotificationFrozenPlan.FrozenTarget> targets =
|
||||
binding.targets().stream()
|
||||
.sorted(Comparator.comparing(target -> target.target().targetId()))
|
||||
.map(
|
||||
target ->
|
||||
new NotificationFrozenPlan.FrozenTarget(
|
||||
binding.targets().stream()
|
||||
.sorted(Comparator.comparing(item -> item.target().targetId()))
|
||||
.toList()
|
||||
.indexOf(target),
|
||||
target.target().targetId(),
|
||||
target.capabilityCard().cardId(),
|
||||
target.runtimeProfile().bindingRevision(),
|
||||
target.runtimeProfile().credentialGeneration()))
|
||||
.toList();
|
||||
NotificationFrozenPlan.BindingSnapshot snapshot =
|
||||
new NotificationFrozenPlan.BindingSnapshot(
|
||||
binding.route().routeRevision(),
|
||||
binding.bindingDigest(),
|
||||
binding.template().checksum(),
|
||||
binding.template().rendererRevision(),
|
||||
targets,
|
||||
binding.route().receiptRequired(),
|
||||
binding.route().perAttemptDeadline());
|
||||
return new NotificationPlanningResult.Planned(
|
||||
NotificationFrozenPlan.from(draft, locale, snapshot));
|
||||
}
|
||||
|
||||
private static boolean matchesPolicy(
|
||||
NotificationRouteDescriptor route, NotificationKindPolicy policy) {
|
||||
return route.channel() == policy.channel()
|
||||
&& route.mode() == policy.mode()
|
||||
&& route.admissionClass() == policy.admissionClass()
|
||||
&& route.routeStrategy() == policy.routeStrategy()
|
||||
&& route.templateRef().equals(policy.templateRef())
|
||||
&& route.maximumTargets() == policy.maxTargetsPerRecipient()
|
||||
&& route.maximumPhysicalAttempts() == policy.maxPhysicalAttemptsPerDelivery()
|
||||
&& route.maximumFallbackActivations() == policy.maxFallbackActivations()
|
||||
&& route.maximumReconcileCalls() == policy.maxReconcileCalls()
|
||||
&& route.maximumTotalProviderCalls() == policy.maxTotalProviderCallsPerIntent();
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.catalog;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationChannel;
|
||||
import dev.caskeleton.application.notification.NotificationMode;
|
||||
import dev.caskeleton.application.notification.NotificationRouteStrategy;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Closed, code-owned capability statement for one qualified provider protocol shape. */
|
||||
public record NotificationProviderCapabilityCard(
|
||||
String cardId,
|
||||
NotificationChannel channel,
|
||||
NotificationMode mode,
|
||||
NotificationRouteStrategy routeStrategy,
|
||||
boolean receiptSupported,
|
||||
boolean reconciliationSupported,
|
||||
boolean hiddenRetriesControlled,
|
||||
boolean terminalIndeterminatePossible,
|
||||
int maximumTargets,
|
||||
int maximumPhysicalAttempts,
|
||||
int maximumReconcileCalls,
|
||||
int maximumTotalProviderCalls,
|
||||
int maximumPayloadBytes) {
|
||||
|
||||
public NotificationProviderCapabilityCard {
|
||||
cardId = NotificationCatalogException.requireSlug("provider capability card ID", cardId);
|
||||
Objects.requireNonNull(channel, "provider capability channel must be non-null");
|
||||
Objects.requireNonNull(mode, "provider capability mode must be non-null");
|
||||
Objects.requireNonNull(routeStrategy, "provider capability route strategy must be non-null");
|
||||
if (maximumTargets < 1
|
||||
|| maximumTargets > 16
|
||||
|| maximumPhysicalAttempts < 1
|
||||
|| maximumPhysicalAttempts > 10
|
||||
|| maximumReconcileCalls < 0
|
||||
|| maximumReconcileCalls > 10
|
||||
|| maximumTotalProviderCalls < 1
|
||||
|| maximumTotalProviderCalls > 64
|
||||
|| maximumPayloadBytes < 1
|
||||
|| maximumPayloadBytes > 10_000_000) {
|
||||
throw new IllegalArgumentException("provider capability bounds are outside supported limits");
|
||||
}
|
||||
if (!hiddenRetriesControlled) {
|
||||
throw new IllegalArgumentException("provider capability must control hidden retries");
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns one of the only three initially qualified provider capability cards. */
|
||||
public static NotificationProviderCapabilityCard initial(String cardId) {
|
||||
return switch (cardId) {
|
||||
case "slack-web-api-inline-single-local-v1" ->
|
||||
new NotificationProviderCapabilityCard(
|
||||
cardId,
|
||||
NotificationChannel.SLACK,
|
||||
NotificationMode.BEST_EFFORT_INLINE,
|
||||
NotificationRouteStrategy.SINGLE,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
32_000);
|
||||
case "slack-web-api-durable-single-local-v1" ->
|
||||
new NotificationProviderCapabilityCard(
|
||||
cardId,
|
||||
NotificationChannel.SLACK,
|
||||
NotificationMode.DURABLE_ASYNC,
|
||||
NotificationRouteStrategy.SINGLE,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
32_000);
|
||||
case "aws-ses-v2-durable-single-local-sns-v1" ->
|
||||
new NotificationProviderCapabilityCard(
|
||||
cardId,
|
||||
NotificationChannel.EMAIL,
|
||||
NotificationMode.DURABLE_ASYNC,
|
||||
NotificationRouteStrategy.SINGLE,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
64_000);
|
||||
default ->
|
||||
throw new NotificationCatalogException(
|
||||
"unknown initial provider capability card: " + cardId);
|
||||
};
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.catalog;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationProviderCapabilityDescriptor;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Derives application-owned actual capability facts from the compiled provider cards. */
|
||||
public final class NotificationProviderCapabilityDescriptorSource {
|
||||
|
||||
private final List<NotificationProviderCapabilityDescriptor> descriptors;
|
||||
|
||||
public NotificationProviderCapabilityDescriptorSource(
|
||||
NotificationBindingCompiler.CompiledGraph graph) {
|
||||
Objects.requireNonNull(graph, "compiled notification graph must be non-null");
|
||||
this.descriptors =
|
||||
graph.bindings().stream()
|
||||
.flatMap(binding -> binding.targets().stream())
|
||||
.map(CompiledNotificationBinding.CompiledTarget::capabilityCard)
|
||||
.distinct()
|
||||
.sorted(Comparator.comparing(NotificationProviderCapabilityCard::cardId))
|
||||
.map(NotificationProviderCapabilityDescriptorSource::toApplication)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<NotificationProviderCapabilityDescriptor> descriptors() {
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
private static NotificationProviderCapabilityDescriptor toApplication(
|
||||
NotificationProviderCapabilityCard card) {
|
||||
return new NotificationProviderCapabilityDescriptor(
|
||||
card.cardId(),
|
||||
card.channel(),
|
||||
Set.of(card.mode()),
|
||||
card.receiptSupported(),
|
||||
card.reconciliationSupported(),
|
||||
card.hiddenRetriesControlled(),
|
||||
card.maximumTargets(),
|
||||
card.maximumPayloadBytes());
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.catalog;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationChannel;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Provider family descriptor; legacy fail-open providers deliberately have no canonical cards. */
|
||||
public record NotificationProviderDescriptor(
|
||||
String providerId,
|
||||
NotificationChannel channel,
|
||||
boolean legacyFailOpen,
|
||||
List<NotificationProviderCapabilityCard> capabilityCards) {
|
||||
|
||||
public NotificationProviderDescriptor {
|
||||
providerId = NotificationCatalogException.requireSlug("notification provider ID", providerId);
|
||||
Objects.requireNonNull(channel, "notification provider channel must be non-null");
|
||||
Objects.requireNonNull(capabilityCards, "provider capability cards must be non-null");
|
||||
capabilityCards =
|
||||
capabilityCards.stream()
|
||||
.map(card -> Objects.requireNonNull(card, "provider capability card must be non-null"))
|
||||
.sorted(Comparator.comparing(NotificationProviderCapabilityCard::cardId))
|
||||
.toList();
|
||||
if (new HashSet<>(
|
||||
capabilityCards.stream().map(NotificationProviderCapabilityCard::cardId).toList())
|
||||
.size()
|
||||
!= capabilityCards.size()) {
|
||||
throw new IllegalArgumentException("provider descriptor contains duplicate capability cards");
|
||||
}
|
||||
if (legacyFailOpen && !capabilityCards.isEmpty()) {
|
||||
throw new IllegalArgumentException("legacy provider cannot advertise canonical capability");
|
||||
}
|
||||
if (!legacyFailOpen && capabilityCards.isEmpty()) {
|
||||
throw new IllegalArgumentException("canonical provider must advertise a capability card");
|
||||
}
|
||||
if (capabilityCards.stream().anyMatch(card -> card.channel() != channel)) {
|
||||
throw new IllegalArgumentException("provider capability card channel must match provider");
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.catalog;
|
||||
|
||||
/** Runtime indirection for secrets and destinations; sensitive references never leave this type. */
|
||||
public record NotificationProviderRuntimeProfile(
|
||||
String profileId,
|
||||
String providerId,
|
||||
String bindingRevision,
|
||||
String capabilityCardId,
|
||||
String credentialGeneration,
|
||||
String credentialReference,
|
||||
String destinationReference) {
|
||||
|
||||
public NotificationProviderRuntimeProfile {
|
||||
profileId = NotificationCatalogException.requireSlug("provider runtime profile ID", profileId);
|
||||
providerId = NotificationCatalogException.requireSlug("provider ID", providerId);
|
||||
bindingRevision =
|
||||
NotificationCatalogException.requireSlug("provider binding revision", bindingRevision);
|
||||
capabilityCardId =
|
||||
NotificationCatalogException.requireSlug("provider capability card ID", capabilityCardId);
|
||||
credentialGeneration =
|
||||
NotificationCatalogException.requireSlug(
|
||||
"provider credential generation", credentialGeneration);
|
||||
credentialReference =
|
||||
NotificationCatalogException.requireOpaque(
|
||||
"provider credential reference", credentialReference);
|
||||
destinationReference =
|
||||
NotificationCatalogException.requireOpaque(
|
||||
"provider destination reference", destinationReference);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "NotificationProviderRuntimeProfile[profileId="
|
||||
+ profileId
|
||||
+ ", providerId="
|
||||
+ providerId
|
||||
+ ", bindingRevision="
|
||||
+ bindingRevision
|
||||
+ ", capabilityCardId="
|
||||
+ capabilityCardId
|
||||
+ ", credentialGeneration="
|
||||
+ credentialGeneration
|
||||
+ ", credentialReference=<redacted>, destinationReference=<redacted>]";
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.catalog;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationAdmissionClass;
|
||||
import dev.caskeleton.application.notification.NotificationChannel;
|
||||
import dev.caskeleton.application.notification.NotificationMode;
|
||||
import dev.caskeleton.application.notification.NotificationRouteId;
|
||||
import dev.caskeleton.application.notification.NotificationRouteStrategy;
|
||||
import dev.caskeleton.application.notification.NotificationTemplateRef;
|
||||
import java.time.Duration;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Provider-neutral route declaration compiled against explicit local catalogs. */
|
||||
public record NotificationRouteDescriptor(
|
||||
NotificationRouteId routeId,
|
||||
int routeRevision,
|
||||
NotificationChannel channel,
|
||||
NotificationMode mode,
|
||||
NotificationAdmissionClass admissionClass,
|
||||
NotificationRouteStrategy routeStrategy,
|
||||
NotificationTemplateRef templateRef,
|
||||
boolean receiptRequired,
|
||||
boolean fallbackAfterIndeterminate,
|
||||
int maximumTargets,
|
||||
int maximumPhysicalAttempts,
|
||||
int maximumFallbackActivations,
|
||||
int maximumReconcileCalls,
|
||||
int maximumTotalProviderCalls,
|
||||
Duration perAttemptDeadline,
|
||||
List<Target> targets) {
|
||||
|
||||
public NotificationRouteDescriptor {
|
||||
Objects.requireNonNull(routeId, "notification route ID must be non-null");
|
||||
if (routeRevision < 1 || routeRevision > 1_000_000) {
|
||||
throw new IllegalArgumentException("notification route revision must be in 1..1000000");
|
||||
}
|
||||
Objects.requireNonNull(channel, "notification route channel must be non-null");
|
||||
Objects.requireNonNull(mode, "notification route mode must be non-null");
|
||||
Objects.requireNonNull(admissionClass, "notification admission class must be non-null");
|
||||
Objects.requireNonNull(routeStrategy, "notification route strategy must be non-null");
|
||||
Objects.requireNonNull(templateRef, "notification template reference must be non-null");
|
||||
if (maximumTargets < 1
|
||||
|| maximumTargets > 16
|
||||
|| maximumPhysicalAttempts < 1
|
||||
|| maximumPhysicalAttempts > 10
|
||||
|| maximumFallbackActivations < 0
|
||||
|| maximumFallbackActivations > 15
|
||||
|| maximumReconcileCalls < 0
|
||||
|| maximumReconcileCalls > 10
|
||||
|| maximumTotalProviderCalls < 1
|
||||
|| maximumTotalProviderCalls > 64) {
|
||||
throw new IllegalArgumentException("notification route bounds are outside supported limits");
|
||||
}
|
||||
Objects.requireNonNull(perAttemptDeadline, "per-attempt deadline must be non-null");
|
||||
if (perAttemptDeadline.isZero()
|
||||
|| perAttemptDeadline.isNegative()
|
||||
|| perAttemptDeadline.compareTo(Duration.ofMinutes(5)) > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"per-attempt deadline must be positive and at most five minutes");
|
||||
}
|
||||
Objects.requireNonNull(targets, "notification route targets must be non-null");
|
||||
targets =
|
||||
targets.stream()
|
||||
.map(target -> Objects.requireNonNull(target, "notification target must be non-null"))
|
||||
.sorted(Comparator.comparing(Target::targetId))
|
||||
.toList();
|
||||
if (targets.isEmpty() || targets.size() > 16) {
|
||||
throw new IllegalArgumentException("notification route targets must contain 1..16 entries");
|
||||
}
|
||||
if (new HashSet<>(targets.stream().map(Target::targetId).toList()).size() != targets.size()) {
|
||||
throw new IllegalArgumentException("notification route contains duplicate target IDs");
|
||||
}
|
||||
}
|
||||
|
||||
/** One provider leg and optional next fallback leg. */
|
||||
public record Target(
|
||||
String targetId, String runtimeProfileId, Optional<String> fallbackTargetId) {
|
||||
|
||||
public Target {
|
||||
targetId = NotificationCatalogException.requireSlug("notification target ID", targetId);
|
||||
runtimeProfileId =
|
||||
NotificationCatalogException.requireSlug(
|
||||
"notification runtime profile ID", runtimeProfileId);
|
||||
Objects.requireNonNull(fallbackTargetId, "fallback target ID container must be non-null");
|
||||
fallbackTargetId =
|
||||
fallbackTargetId.map(
|
||||
value -> NotificationCatalogException.requireSlug("fallback target ID", value));
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.catalog;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationChannel;
|
||||
import dev.caskeleton.application.notification.NotificationTemplateRef;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Immutable checked-in template manifest metadata used by the binding compiler. */
|
||||
public record NotificationTemplateDescriptor(
|
||||
NotificationTemplateRef templateRef,
|
||||
NotificationChannel channel,
|
||||
String rendererRevision,
|
||||
String checksum,
|
||||
Set<Locale> supportedLocales,
|
||||
Locale fallbackLocale,
|
||||
Set<String> parameterNames,
|
||||
int maximumRenderedBytes) {
|
||||
|
||||
public NotificationTemplateDescriptor {
|
||||
Objects.requireNonNull(templateRef, "notification template reference must be non-null");
|
||||
Objects.requireNonNull(channel, "notification template channel must be non-null");
|
||||
rendererRevision =
|
||||
NotificationCatalogException.requireSlug(
|
||||
"notification renderer revision", rendererRevision);
|
||||
checksum =
|
||||
NotificationCatalogException.requireDigest("notification template checksum", checksum);
|
||||
Objects.requireNonNull(supportedLocales, "supported template locales must be non-null");
|
||||
supportedLocales =
|
||||
supportedLocales.stream()
|
||||
.map(locale -> requireLocale("supported template locale", locale))
|
||||
.sorted(Comparator.comparing(Locale::toLanguageTag))
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
if (supportedLocales.isEmpty() || supportedLocales.size() > 32) {
|
||||
throw new IllegalArgumentException("supported template locales must contain 1..32 entries");
|
||||
}
|
||||
fallbackLocale = requireLocale("template fallback locale", fallbackLocale);
|
||||
if (!supportedLocales.contains(fallbackLocale)) {
|
||||
throw new IllegalArgumentException("template fallback locale must be supported");
|
||||
}
|
||||
Objects.requireNonNull(parameterNames, "template parameter names must be non-null");
|
||||
parameterNames =
|
||||
parameterNames.stream()
|
||||
.map(NotificationTemplateDescriptor::requireParameterName)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
if (parameterNames.size() > 32) {
|
||||
throw new IllegalArgumentException("template parameter names exceed 32 entries");
|
||||
}
|
||||
if (new HashSet<>(parameterNames).size() != parameterNames.size()) {
|
||||
throw new IllegalArgumentException("template parameter names contain duplicates");
|
||||
}
|
||||
if (maximumRenderedBytes < 1 || maximumRenderedBytes > 10_000_000) {
|
||||
throw new IllegalArgumentException("maximum rendered bytes must be in 1..10000000");
|
||||
}
|
||||
}
|
||||
|
||||
Locale selectLocale(Locale requested) {
|
||||
Locale normalized = requireLocale("requested template locale", requested);
|
||||
return supportedLocales.contains(normalized) ? normalized : fallbackLocale;
|
||||
}
|
||||
|
||||
private static Locale requireLocale(String field, Locale locale) {
|
||||
Objects.requireNonNull(locale, field + " must be non-null");
|
||||
String tag = locale.toLanguageTag();
|
||||
if (locale.equals(Locale.ROOT) || tag.equals("und") || tag.isBlank() || tag.length() > 35) {
|
||||
throw new IllegalArgumentException(field + " must be an explicit bounded locale");
|
||||
}
|
||||
return Locale.forLanguageTag(tag);
|
||||
}
|
||||
|
||||
private static String requireParameterName(String name) {
|
||||
if (name == null || !name.matches("[a-z][A-Za-z0-9]{0,63}")) {
|
||||
throw new IllegalArgumentException(
|
||||
"template parameter name must match [a-z][A-Za-z0-9]{0,63}");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/** Opaque non-PII correlation identity created before a provider send. */
|
||||
public record AttemptCorrelationId(String value)
|
||||
implements NotificationProviderAttemptClient.ReconciliationReference {
|
||||
|
||||
public AttemptCorrelationId {
|
||||
value = requireOpaque("attempt correlation ID", value);
|
||||
}
|
||||
|
||||
static AttemptCorrelationId derive(String seed) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return new AttemptCorrelationId(
|
||||
"ca-" + HexFormat.of().formatHex(digest.digest(seed.getBytes(StandardCharsets.UTF_8))));
|
||||
} catch (NoSuchAlgorithmException unavailable) {
|
||||
throw new IllegalStateException(
|
||||
"SHA-256 must be available on every Java runtime", unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
static String requireOpaque(String field, String value) {
|
||||
if (value == null || !value.matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")) {
|
||||
throw new IllegalArgumentException(
|
||||
field + " must contain 1..128 opaque identifier characters");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
import dev.caskeleton.application.notification.InlineNotificationAttemptPort;
|
||||
import dev.caskeleton.application.notification.NotificationAttemptId;
|
||||
import dev.caskeleton.application.notification.NotificationDeliveryId;
|
||||
import dev.caskeleton.application.notification.NotificationFrozenPlan;
|
||||
import dev.caskeleton.application.notification.NotificationMode;
|
||||
import dev.caskeleton.application.notification.NotificationRequestResult;
|
||||
import dev.caskeleton.application.notification.TargetAttemptOutcome;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Bounded inline port using the same internal one-authorized-attempt protocol. */
|
||||
public final class InlineNotificationAttemptAdapter implements InlineNotificationAttemptPort {
|
||||
|
||||
private final NotificationProviderAttemptAdapter attempts;
|
||||
|
||||
public InlineNotificationAttemptAdapter(NotificationProviderAttemptAdapter attempts) {
|
||||
this.attempts = Objects.requireNonNull(attempts, "provider attempt adapter must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationRequestResult.InlineCompleted attempt(NotificationFrozenPlan plan) {
|
||||
Objects.requireNonNull(plan, "notification frozen plan must be non-null");
|
||||
if (plan.mode() != NotificationMode.BEST_EFFORT_INLINE) {
|
||||
throw new IllegalArgumentException("inline attempt requires BEST_EFFORT_INLINE plan");
|
||||
}
|
||||
List<TargetAttemptOutcome> outcomes = new ArrayList<>();
|
||||
for (int ordinal = 0; ordinal < plan.binding().targets().size(); ordinal++) {
|
||||
String suffix =
|
||||
AttemptCorrelationId.derive(plan.intentId().value() + ":" + ordinal)
|
||||
.value()
|
||||
.substring(3, 35);
|
||||
NotificationDeliveryId deliveryId = new NotificationDeliveryId("inline-delivery-" + suffix);
|
||||
NotificationAttemptId attemptId = new NotificationAttemptId("inline-attempt-" + suffix);
|
||||
String executionToken = "inline-execution-" + suffix;
|
||||
Instant deadline =
|
||||
minimum(plan.expiresAt(), attempts.now().plus(plan.binding().perAttemptDeadline()));
|
||||
outcomes.add(
|
||||
new TargetAttemptOutcome(
|
||||
ordinal,
|
||||
deliveryId,
|
||||
attempts.attemptInline(
|
||||
plan, ordinal, deliveryId, attemptId, executionToken, deadline)));
|
||||
}
|
||||
return new NotificationRequestResult.InlineCompleted(plan.intentId(), outcomes);
|
||||
}
|
||||
|
||||
private static Instant minimum(Instant left, Instant right) {
|
||||
return left.isBefore(right) ? left : right;
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile;
|
||||
import dev.caskeleton.application.notification.NotificationAdmissionReadinessPort;
|
||||
import dev.caskeleton.application.notification.NotificationReasonCode;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Bridges application admission operations to exact adapter-internal provider readiness evidence.
|
||||
*/
|
||||
public final class NotificationAdmissionReadinessAdapter
|
||||
implements NotificationAdmissionReadinessPort {
|
||||
|
||||
private static final int MAXIMUM_PENDING_PROBES = 1_024;
|
||||
private static final Duration PROBE_DEADLINE = Duration.ofSeconds(30);
|
||||
private static final NotificationReasonCode PROBE_FAILED =
|
||||
new NotificationReasonCode("READINESS_PROBE_FAILED");
|
||||
private static final NotificationReasonCode TOKEN_CONFLICT =
|
||||
new NotificationReasonCode("READINESS_TOKEN_CONFLICT");
|
||||
private static final NotificationReasonCode CAPACITY_EXCEEDED =
|
||||
new NotificationReasonCode("READINESS_CAPACITY_EXCEEDED");
|
||||
|
||||
private final NotificationAdmissionReadinessPort state;
|
||||
private final Function<ResumeRequest, NotificationProviderRuntimeProfile> profiles;
|
||||
private final NotificationProviderReadinessProbe readiness;
|
||||
private final Clock clock;
|
||||
private final ConcurrentHashMap<String, CapturedProbe> fresh = new ConcurrentHashMap<>();
|
||||
|
||||
public NotificationAdmissionReadinessAdapter(
|
||||
NotificationAdmissionReadinessPort state,
|
||||
Function<ResumeRequest, NotificationProviderRuntimeProfile> profiles,
|
||||
NotificationProviderReadinessProbe readiness,
|
||||
Clock clock) {
|
||||
this.state = Objects.requireNonNull(state, "admission state port must be non-null");
|
||||
this.profiles = Objects.requireNonNull(profiles, "runtime profile resolver must be non-null");
|
||||
this.readiness = Objects.requireNonNull(readiness, "provider readiness probe must be non-null");
|
||||
this.clock = Objects.requireNonNull(clock, "provider readiness clock must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParkResult park(ParkRequest request) {
|
||||
return Objects.requireNonNull(state.park(request), "admission park result must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReadinessProbe probe(ResumeRequest request) {
|
||||
Objects.requireNonNull(request, "admission resume request must be non-null");
|
||||
Instant probeStartedAt = clock.instant();
|
||||
evictExpired(probeStartedAt);
|
||||
try {
|
||||
NotificationProviderRuntimeProfile profile =
|
||||
Objects.requireNonNull(profiles.apply(request), "runtime profile must be non-null");
|
||||
CapturedProbe existing = fresh.get(request.operationToken());
|
||||
if (existing != null) {
|
||||
if (!existing.request().equals(request) || !existing.profile().equals(profile)) {
|
||||
return new ReadinessProbe(false, TOKEN_CONFLICT);
|
||||
}
|
||||
return new ReadinessProbe(existing.snapshot().ready(), existing.snapshot().reasonCode());
|
||||
}
|
||||
NotificationProviderReadinessSnapshot snapshot =
|
||||
Objects.requireNonNull(
|
||||
readiness.probe(profile, probeStartedAt.plus(PROBE_DEADLINE)),
|
||||
"provider readiness snapshot must be non-null");
|
||||
Instant validatedAt = clock.instant();
|
||||
validateExact(profile, snapshot, validatedAt);
|
||||
if (snapshot.ready()) {
|
||||
if (fresh.size() >= MAXIMUM_PENDING_PROBES) {
|
||||
return new ReadinessProbe(false, CAPACITY_EXCEEDED);
|
||||
}
|
||||
CapturedProbe captured = new CapturedProbe(request, profile, snapshot);
|
||||
CapturedProbe collision = fresh.putIfAbsent(request.operationToken(), captured);
|
||||
if (collision != null && !collision.equals(captured)) {
|
||||
return new ReadinessProbe(false, TOKEN_CONFLICT);
|
||||
}
|
||||
}
|
||||
return new ReadinessProbe(snapshot.ready(), snapshot.reasonCode());
|
||||
} catch (RuntimeException probeFailure) {
|
||||
return new ReadinessProbe(false, PROBE_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResumeResult resume(ResumeRequest request, ReadinessProbe probe, Instant resumedAt) {
|
||||
Objects.requireNonNull(request, "admission resume request must be non-null");
|
||||
Objects.requireNonNull(probe, "application readiness probe must be non-null");
|
||||
Objects.requireNonNull(resumedAt, "admission resume time must be non-null");
|
||||
CapturedProbe captured = fresh.remove(request.operationToken());
|
||||
if (captured == null
|
||||
|| !captured.request().equals(request)
|
||||
|| !probe.ready()
|
||||
|| !captured.snapshot().reasonCode().equals(probe.reasonCode())
|
||||
|| !captured.snapshot().expiresAt().isAfter(resumedAt)) {
|
||||
throw new IllegalStateException(
|
||||
"admission resume requires fresh exact provider readiness evidence");
|
||||
}
|
||||
NotificationProviderRuntimeProfile currentProfile;
|
||||
try {
|
||||
currentProfile =
|
||||
Objects.requireNonNull(profiles.apply(request), "runtime profile must be non-null");
|
||||
} catch (RuntimeException profileFailure) {
|
||||
throw new IllegalStateException(
|
||||
"admission resume requires fresh exact provider readiness evidence");
|
||||
}
|
||||
if (!captured.profile().equals(currentProfile)) {
|
||||
throw new IllegalStateException(
|
||||
"admission resume requires the exact probed provider generation");
|
||||
}
|
||||
Instant freshAt = clock.instant();
|
||||
if (resumedAt.isAfter(freshAt) || !captured.snapshot().expiresAt().isAfter(freshAt)) {
|
||||
throw new IllegalStateException(
|
||||
"admission resume requires currently fresh provider readiness evidence");
|
||||
}
|
||||
return Objects.requireNonNull(
|
||||
state.resume(request, probe, freshAt), "admission resume result must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "NotificationAdmissionReadinessAdapter[state=<redacted>, profiles=<redacted>, "
|
||||
+ "readiness=<redacted>, pendingProbeCount="
|
||||
+ fresh.size()
|
||||
+ "]";
|
||||
}
|
||||
|
||||
private static void validateExact(
|
||||
NotificationProviderRuntimeProfile profile,
|
||||
NotificationProviderReadinessSnapshot snapshot,
|
||||
Instant now) {
|
||||
if (!snapshot.profileId().equals(profile.profileId())
|
||||
|| !snapshot.bindingRevision().equals(profile.bindingRevision())
|
||||
|| !snapshot.capabilityCardId().equals(profile.capabilityCardId())
|
||||
|| !snapshot.credentialGeneration().equals(profile.credentialGeneration())) {
|
||||
throw new IllegalStateException(
|
||||
"provider readiness snapshot does not match the exact runtime profile");
|
||||
}
|
||||
if (snapshot.observedAt().isAfter(now)
|
||||
|| !snapshot.expiresAt().isAfter(now)
|
||||
|| snapshot.observedAt().isAfter(snapshot.expiresAt())) {
|
||||
throw new IllegalStateException("provider readiness snapshot is not currently valid");
|
||||
}
|
||||
}
|
||||
|
||||
private void evictExpired(Instant now) {
|
||||
fresh.entrySet().removeIf(entry -> !entry.getValue().snapshot().expiresAt().isAfter(now));
|
||||
}
|
||||
|
||||
private record CapturedProbe(
|
||||
ResumeRequest request,
|
||||
NotificationProviderRuntimeProfile profile,
|
||||
NotificationProviderReadinessSnapshot snapshot) {
|
||||
|
||||
private CapturedProbe {
|
||||
Objects.requireNonNull(request, "captured resume request must be non-null");
|
||||
Objects.requireNonNull(profile, "captured runtime profile must be non-null");
|
||||
Objects.requireNonNull(snapshot, "captured readiness snapshot must be non-null");
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationAttemptId;
|
||||
import dev.caskeleton.application.notification.NotificationDeliveryId;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Immutable one-attempt identities and absolute deadline passed to provider preparation. */
|
||||
public record NotificationAttemptContext(
|
||||
NotificationDeliveryId deliveryId,
|
||||
NotificationAttemptId attemptId,
|
||||
int targetOrdinal,
|
||||
AttemptCorrelationId correlationId,
|
||||
Optional<ProviderClientOperationKey> clientOperationKey,
|
||||
Instant absoluteDeadline) {
|
||||
|
||||
public NotificationAttemptContext {
|
||||
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
|
||||
Objects.requireNonNull(attemptId, "notification attempt ID must be non-null");
|
||||
if (targetOrdinal < 0 || targetOrdinal > 15) {
|
||||
throw new IllegalArgumentException("notification target ordinal must be in 0..15");
|
||||
}
|
||||
Objects.requireNonNull(correlationId, "attempt correlation ID must be non-null");
|
||||
Objects.requireNonNull(
|
||||
clientOperationKey, "provider client operation key container must be non-null");
|
||||
Objects.requireNonNull(absoluteDeadline, "attempt absolute deadline must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "NotificationAttemptContext[deliveryId=<redacted>, attemptId="
|
||||
+ attemptId
|
||||
+ ", targetOrdinal="
|
||||
+ targetOrdinal
|
||||
+ ", correlationId=<redacted>, clientOperationKey=<redacted>, absoluteDeadline="
|
||||
+ absoluteDeadline
|
||||
+ "]";
|
||||
}
|
||||
|
||||
/** Native provider operation key, distinct from local correlation and provider message IDs. */
|
||||
public record ProviderClientOperationKey(String value)
|
||||
implements NotificationProviderAttemptClient.ReconciliationReference {
|
||||
|
||||
public ProviderClientOperationKey {
|
||||
value = AttemptCorrelationId.requireOpaque("provider client operation key", value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ProviderClientOperationKey[value=<redacted>]";
|
||||
}
|
||||
}
|
||||
}
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding;
|
||||
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationBindingCompiler;
|
||||
import dev.caskeleton.adapter.outbound.notification.template.NotificationTemplateRenderer;
|
||||
import dev.caskeleton.adapter.outbound.notification.template.RenderedNotification;
|
||||
import dev.caskeleton.application.notification.NotificationAttemptId;
|
||||
import dev.caskeleton.application.notification.NotificationDeliveryId;
|
||||
import dev.caskeleton.application.notification.NotificationDeliveryStorePort;
|
||||
import dev.caskeleton.application.notification.NotificationFaultScope;
|
||||
import dev.caskeleton.application.notification.NotificationFrozenPlan;
|
||||
import dev.caskeleton.application.notification.NotificationProviderAttemptPort;
|
||||
import dev.caskeleton.application.notification.NotificationReasonCode;
|
||||
import dev.caskeleton.application.notification.ProviderAttemptOutcome;
|
||||
import dev.caskeleton.application.notification.RetryDisposition;
|
||||
import dev.caskeleton.application.notification.SubmissionCertainty;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Application provider port backed by one compiled binding, renderer and internal client call. */
|
||||
public final class NotificationProviderAttemptAdapter implements NotificationProviderAttemptPort {
|
||||
|
||||
private final NotificationBindingCompiler.CompiledGraph graph;
|
||||
private final Map<String, NotificationTemplateRenderer<? extends RenderedNotification>> renderers;
|
||||
private final Map<String, NotificationProviderAttemptClient> clients;
|
||||
private final NotificationProviderRateAdmission rateAdmission;
|
||||
private final NotificationProviderSecretMaterialProvider secrets;
|
||||
private final Clock clock;
|
||||
|
||||
public NotificationProviderAttemptAdapter(
|
||||
NotificationBindingCompiler.CompiledGraph graph,
|
||||
Map<String, NotificationTemplateRenderer<? extends RenderedNotification>> renderers,
|
||||
Map<String, NotificationProviderAttemptClient> clients,
|
||||
NotificationProviderRateAdmission rateAdmission,
|
||||
NotificationProviderSecretMaterialProvider secrets,
|
||||
Clock clock) {
|
||||
this.graph = Objects.requireNonNull(graph, "compiled notification graph must be non-null");
|
||||
this.renderers = Map.copyOf(Objects.requireNonNull(renderers, "renderers must be non-null"));
|
||||
this.clients = Map.copyOf(Objects.requireNonNull(clients, "provider clients must be non-null"));
|
||||
this.rateAdmission =
|
||||
Objects.requireNonNull(rateAdmission, "provider rate admission must be non-null");
|
||||
this.secrets =
|
||||
Objects.requireNonNull(secrets, "provider secret material provider must be non-null");
|
||||
this.clock = Objects.requireNonNull(clock, "provider attempt clock must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderAttemptOutcome attempt(NotificationDeliveryStorePort.AuthorizedAttempt attempt) {
|
||||
Objects.requireNonNull(attempt, "authorized notification attempt must be non-null");
|
||||
Instant deadline =
|
||||
minimum(
|
||||
attempt.absoluteDeadline(),
|
||||
clock.instant().plus(attempt.plan().binding().perAttemptDeadline()));
|
||||
return attemptOne(
|
||||
attempt.plan(),
|
||||
attempt.targetOrdinal(),
|
||||
attempt.deliveryId(),
|
||||
attempt.attemptId(),
|
||||
attempt.executionToken(),
|
||||
deadline);
|
||||
}
|
||||
|
||||
ProviderAttemptOutcome attemptInline(
|
||||
NotificationFrozenPlan plan,
|
||||
int targetOrdinal,
|
||||
NotificationDeliveryId deliveryId,
|
||||
NotificationAttemptId attemptId,
|
||||
String executionToken,
|
||||
Instant deadline) {
|
||||
return attemptOne(plan, targetOrdinal, deliveryId, attemptId, executionToken, deadline);
|
||||
}
|
||||
|
||||
Instant now() {
|
||||
return clock.instant();
|
||||
}
|
||||
|
||||
private ProviderAttemptOutcome attemptOne(
|
||||
NotificationFrozenPlan plan,
|
||||
int targetOrdinal,
|
||||
NotificationDeliveryId deliveryId,
|
||||
NotificationAttemptId attemptId,
|
||||
String executionToken,
|
||||
Instant deadline) {
|
||||
Objects.requireNonNull(plan, "notification frozen plan must be non-null");
|
||||
AttemptCorrelationId correlation =
|
||||
AttemptCorrelationId.derive(executionToken + ":" + targetOrdinal);
|
||||
if (!deadline.isAfter(clock.instant())) {
|
||||
return definitelyNotApplied(
|
||||
correlation,
|
||||
RetryDisposition.TERMINAL,
|
||||
NotificationFaultScope.DELIVERY,
|
||||
"ATTEMPT_DEADLINE_EXPIRED",
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
CompiledNotificationBinding binding;
|
||||
CompiledNotificationBinding.CompiledTarget target;
|
||||
try {
|
||||
binding = requireBinding(plan);
|
||||
target = requireTarget(binding, plan, targetOrdinal);
|
||||
} catch (RuntimeException retainedBindingFailure) {
|
||||
return definitelyNotApplied(
|
||||
correlation,
|
||||
RetryDisposition.PARK_BINDING,
|
||||
NotificationFaultScope.ROUTE_REVISION,
|
||||
"FROZEN_BINDING_UNAVAILABLE",
|
||||
Optional.empty());
|
||||
}
|
||||
NotificationProviderRateAdmission.Decision admission;
|
||||
try {
|
||||
admission =
|
||||
Objects.requireNonNull(
|
||||
rateAdmission.admit(target.runtimeProfile(), deadline),
|
||||
"provider rate admission decision must be non-null");
|
||||
} catch (RuntimeException admissionFailure) {
|
||||
return definitelyNotApplied(
|
||||
correlation,
|
||||
RetryDisposition.PARK_BINDING,
|
||||
NotificationFaultScope.PROVIDER_BINDING,
|
||||
"PROVIDER_RATE_ADMISSION_FAILED",
|
||||
Optional.empty());
|
||||
}
|
||||
if (!admission.admitted()) {
|
||||
Instant retryAt = admission.retryNotBefore().orElseThrow();
|
||||
if (!retryAt.isAfter(clock.instant()) || retryAt.isAfter(deadline)) {
|
||||
return definitelyNotApplied(
|
||||
correlation,
|
||||
RetryDisposition.PARK_BINDING,
|
||||
NotificationFaultScope.PROVIDER_BINDING,
|
||||
"PROVIDER_RETRY_BOUND_INVALID",
|
||||
Optional.empty());
|
||||
}
|
||||
return definitelyNotApplied(
|
||||
correlation,
|
||||
RetryDisposition.RETRY_AT,
|
||||
NotificationFaultScope.PROVIDER_BINDING,
|
||||
admission.reasonCode().value(),
|
||||
admission.retryNotBefore());
|
||||
}
|
||||
|
||||
NotificationTemplateRenderer<? extends RenderedNotification> renderer =
|
||||
renderers.get(binding.template().rendererRevision());
|
||||
NotificationProviderAttemptClient client = clients.get(target.capabilityCard().cardId());
|
||||
if (renderer == null || client == null) {
|
||||
return definitelyNotApplied(
|
||||
correlation,
|
||||
RetryDisposition.PARK_BINDING,
|
||||
NotificationFaultScope.ROUTE_REVISION,
|
||||
"PROVIDER_BINDING_COMPONENT_UNAVAILABLE",
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
NotificationAttemptContext context =
|
||||
new NotificationAttemptContext(
|
||||
deliveryId, attemptId, targetOrdinal, correlation, Optional.empty(), deadline);
|
||||
RenderedNotification rendered;
|
||||
try {
|
||||
rendered =
|
||||
Objects.requireNonNull(renderer.render(plan), "rendered notification must be non-null");
|
||||
} catch (RuntimeException renderingFailure) {
|
||||
return definitelyNotApplied(
|
||||
correlation,
|
||||
RetryDisposition.PARK_BINDING,
|
||||
NotificationFaultScope.ROUTE_REVISION,
|
||||
"TEMPLATE_RENDERING_FAILED",
|
||||
Optional.empty());
|
||||
}
|
||||
if (rendered.utf8Bytes() > binding.template().maximumRenderedBytes()
|
||||
|| rendered.utf8Bytes() > target.capabilityCard().maximumPayloadBytes()) {
|
||||
return definitelyNotApplied(
|
||||
correlation,
|
||||
RetryDisposition.PARK_BINDING,
|
||||
NotificationFaultScope.ROUTE_REVISION,
|
||||
"RENDERED_PAYLOAD_BOUND_EXCEEDED",
|
||||
Optional.empty());
|
||||
}
|
||||
PreparedNotificationAttempt prepared;
|
||||
try {
|
||||
prepared =
|
||||
Objects.requireNonNull(
|
||||
client.prepare(rendered, target, context),
|
||||
"prepared notification attempt must be non-null");
|
||||
if (!prepared.rendered().equals(rendered)
|
||||
|| !prepared.target().equals(target)
|
||||
|| !prepared.context().equals(context)) {
|
||||
return definitelyNotApplied(
|
||||
correlation,
|
||||
RetryDisposition.PARK_BINDING,
|
||||
NotificationFaultScope.PROVIDER_BINDING,
|
||||
"PROVIDER_PREPARED_ATTEMPT_MISMATCH",
|
||||
Optional.empty());
|
||||
}
|
||||
} catch (NotificationProviderAttemptClient.PreWireDeliveryRejectedException rejected) {
|
||||
return definitelyNotApplied(
|
||||
correlation,
|
||||
RetryDisposition.TERMINAL,
|
||||
NotificationFaultScope.DELIVERY,
|
||||
"PROVIDER_PREWIRE_VALIDATION_FAILED",
|
||||
Optional.empty());
|
||||
} catch (RuntimeException bindingFailure) {
|
||||
return definitelyNotApplied(
|
||||
correlation,
|
||||
RetryDisposition.PARK_BINDING,
|
||||
NotificationFaultScope.PROVIDER_BINDING,
|
||||
"PROVIDER_PREWIRE_BINDING_FAILED",
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
NotificationSecretMaterialHandle acquired;
|
||||
try {
|
||||
acquired =
|
||||
Objects.requireNonNull(
|
||||
secrets.acquire(target.runtimeProfile()),
|
||||
"provider secret material handle must be non-null");
|
||||
} catch (RuntimeException secretFailure) {
|
||||
return definitelyNotApplied(
|
||||
correlation,
|
||||
RetryDisposition.PARK_BINDING,
|
||||
NotificationFaultScope.PROVIDER_BINDING,
|
||||
"PROVIDER_SECRET_ACQUISITION_FAILED",
|
||||
Optional.empty());
|
||||
}
|
||||
try (NotificationSecretMaterialHandle secret = acquired) {
|
||||
if (!secret.revision().equals(target.runtimeProfile().credentialGeneration())) {
|
||||
return definitelyNotApplied(
|
||||
correlation,
|
||||
RetryDisposition.PARK_BINDING,
|
||||
NotificationFaultScope.PROVIDER_BINDING,
|
||||
"PROVIDER_SECRET_GENERATION_MISMATCH",
|
||||
Optional.empty());
|
||||
}
|
||||
Instant beforeWire = clock.instant();
|
||||
if (!deadline.isAfter(beforeWire)) {
|
||||
return definitelyNotApplied(
|
||||
correlation,
|
||||
RetryDisposition.TERMINAL,
|
||||
NotificationFaultScope.DELIVERY,
|
||||
"ATTEMPT_DEADLINE_EXPIRED",
|
||||
Optional.empty());
|
||||
}
|
||||
try {
|
||||
NotificationProviderAttemptClient.ClientAttemptResult result =
|
||||
Objects.requireNonNull(
|
||||
client.sendOneAuthorizedAttempt(prepared, executionToken, secret, deadline),
|
||||
"provider client result must be non-null");
|
||||
return map(result, correlation, clock.instant(), deadline);
|
||||
} catch (RuntimeException possibleWriteFailure) {
|
||||
return new ProviderAttemptOutcome(
|
||||
SubmissionCertainty.INDETERMINATE,
|
||||
RetryDisposition.NOT_APPLICABLE,
|
||||
NotificationFaultScope.DELIVERY,
|
||||
new NotificationReasonCode("PROVIDER_RESPONSE_INDETERMINATE"),
|
||||
Optional.empty(),
|
||||
correlation.value(),
|
||||
Optional.empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private CompiledNotificationBinding requireBinding(NotificationFrozenPlan plan) {
|
||||
return graph.bindings().stream()
|
||||
.filter(binding -> binding.bindingDigest().equals(plan.binding().bindingDigest()))
|
||||
.filter(binding -> binding.route().routeId().equals(plan.routeId()))
|
||||
.filter(binding -> binding.route().routeRevision() == plan.binding().routeRevision())
|
||||
.findFirst()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalArgumentException(
|
||||
"frozen plan is outside the compiled notification binding graph"));
|
||||
}
|
||||
|
||||
private static CompiledNotificationBinding.CompiledTarget requireTarget(
|
||||
CompiledNotificationBinding binding, NotificationFrozenPlan plan, int ordinal) {
|
||||
java.util.List<CompiledNotificationBinding.CompiledTarget> ordered =
|
||||
binding.targets().stream()
|
||||
.sorted(Comparator.comparing(target -> target.target().targetId()))
|
||||
.toList();
|
||||
if (ordinal < 0 || ordinal >= ordered.size()) {
|
||||
throw new IllegalArgumentException("notification target ordinal is outside binding");
|
||||
}
|
||||
CompiledNotificationBinding.CompiledTarget target = ordered.get(ordinal);
|
||||
NotificationFrozenPlan.FrozenTarget frozen = plan.binding().targets().get(ordinal);
|
||||
if (!frozen.targetReference().equals(target.target().targetId())
|
||||
|| !frozen.providerCapabilityReference().equals(target.capabilityCard().cardId())
|
||||
|| !frozen.providerBindingRevision().equals(target.runtimeProfile().bindingRevision())
|
||||
|| !frozen.credentialGeneration().equals(target.runtimeProfile().credentialGeneration())) {
|
||||
throw new IllegalArgumentException(
|
||||
"frozen notification target does not match compiled binding");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private static ProviderAttemptOutcome map(
|
||||
NotificationProviderAttemptClient.ClientAttemptResult result,
|
||||
AttemptCorrelationId correlation,
|
||||
Instant observedAt,
|
||||
Instant deadline) {
|
||||
return switch (result) {
|
||||
case NotificationProviderAttemptClient.ClientAttemptResult.Accepted accepted ->
|
||||
new ProviderAttemptOutcome(
|
||||
SubmissionCertainty.PROVIDER_ACCEPTED,
|
||||
RetryDisposition.NOT_APPLICABLE,
|
||||
NotificationFaultScope.DELIVERY,
|
||||
new NotificationReasonCode("PROVIDER_ACCEPTED"),
|
||||
Optional.empty(),
|
||||
correlation.value(),
|
||||
Optional.of(accepted.providerMessageReference().value()));
|
||||
case NotificationProviderAttemptClient.ClientAttemptResult.DefinitelyNotApplied rejected -> {
|
||||
if (rejected.retryDisposition() == RetryDisposition.RETRY_AT
|
||||
&& (rejected.retryNotBefore().orElseThrow().isAfter(deadline)
|
||||
|| !rejected.retryNotBefore().orElseThrow().isAfter(observedAt))) {
|
||||
yield definitelyNotApplied(
|
||||
correlation,
|
||||
RetryDisposition.PARK_BINDING,
|
||||
NotificationFaultScope.PROVIDER_BINDING,
|
||||
"PROVIDER_RETRY_BOUND_INVALID",
|
||||
Optional.empty());
|
||||
}
|
||||
yield new ProviderAttemptOutcome(
|
||||
SubmissionCertainty.DEFINITELY_NOT_APPLIED,
|
||||
rejected.retryDisposition(),
|
||||
rejected.faultScope(),
|
||||
rejected.reasonCode(),
|
||||
rejected.retryNotBefore(),
|
||||
correlation.value(),
|
||||
Optional.empty());
|
||||
}
|
||||
case NotificationProviderAttemptClient.ClientAttemptResult.Indeterminate indeterminate ->
|
||||
new ProviderAttemptOutcome(
|
||||
SubmissionCertainty.INDETERMINATE,
|
||||
RetryDisposition.NOT_APPLICABLE,
|
||||
NotificationFaultScope.DELIVERY,
|
||||
indeterminate.reasonCode(),
|
||||
Optional.empty(),
|
||||
correlation.value(),
|
||||
Optional.empty());
|
||||
};
|
||||
}
|
||||
|
||||
private static ProviderAttemptOutcome definitelyNotApplied(
|
||||
AttemptCorrelationId correlation,
|
||||
RetryDisposition retryDisposition,
|
||||
NotificationFaultScope scope,
|
||||
String reasonCode,
|
||||
Optional<Instant> retryNotBefore) {
|
||||
return new ProviderAttemptOutcome(
|
||||
SubmissionCertainty.DEFINITELY_NOT_APPLIED,
|
||||
retryDisposition,
|
||||
scope,
|
||||
new NotificationReasonCode(reasonCode),
|
||||
retryNotBefore,
|
||||
correlation.value(),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
private static Instant minimum(Instant left, Instant right) {
|
||||
return left.isBefore(right) ? left : right;
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding;
|
||||
import dev.caskeleton.adapter.outbound.notification.template.RenderedNotification;
|
||||
import dev.caskeleton.application.notification.NotificationFaultScope;
|
||||
import dev.caskeleton.application.notification.NotificationReasonCode;
|
||||
import dev.caskeleton.application.notification.RetryDisposition;
|
||||
import dev.caskeleton.application.notification.SubmissionCertainty;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Adapter-internal provider protocol: pure prepare, then one authorized wire attempt. */
|
||||
public interface NotificationProviderAttemptClient {
|
||||
|
||||
PreparedNotificationAttempt prepare(
|
||||
RenderedNotification rendered,
|
||||
CompiledNotificationBinding.CompiledTarget target,
|
||||
NotificationAttemptContext context);
|
||||
|
||||
ClientAttemptResult sendOneAuthorizedAttempt(
|
||||
PreparedNotificationAttempt prepared,
|
||||
String executionToken,
|
||||
NotificationSecretMaterialHandle secret,
|
||||
Instant absoluteDeadline);
|
||||
|
||||
default ClientReconciliationResult reconcile(
|
||||
ReconciliationReference reference,
|
||||
ReconciliationLookupMode lookupMode,
|
||||
NotificationSecretMaterialHandle secret,
|
||||
Instant absoluteDeadline) {
|
||||
return new ClientReconciliationResult(
|
||||
SubmissionCertainty.INDETERMINATE,
|
||||
new NotificationReasonCode("RECONCILIATION_UNSUPPORTED"));
|
||||
}
|
||||
|
||||
sealed interface ReconciliationReference
|
||||
permits AttemptCorrelationId,
|
||||
ProviderMessageReference,
|
||||
NotificationAttemptContext.ProviderClientOperationKey {
|
||||
|
||||
String value();
|
||||
}
|
||||
|
||||
sealed interface ClientAttemptResult
|
||||
permits ClientAttemptResult.Accepted,
|
||||
ClientAttemptResult.DefinitelyNotApplied,
|
||||
ClientAttemptResult.Indeterminate {
|
||||
|
||||
record Accepted(ProviderMessageReference providerMessageReference)
|
||||
implements ClientAttemptResult {
|
||||
|
||||
public Accepted {
|
||||
Objects.requireNonNull(
|
||||
providerMessageReference, "provider message reference must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record DefinitelyNotApplied(
|
||||
RetryDisposition retryDisposition,
|
||||
NotificationFaultScope faultScope,
|
||||
NotificationReasonCode reasonCode,
|
||||
Optional<Instant> retryNotBefore)
|
||||
implements ClientAttemptResult {
|
||||
|
||||
public DefinitelyNotApplied {
|
||||
Objects.requireNonNull(retryDisposition, "retry disposition must be non-null");
|
||||
Objects.requireNonNull(faultScope, "notification fault scope must be non-null");
|
||||
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
|
||||
Objects.requireNonNull(retryNotBefore, "retry-not-before container must be non-null");
|
||||
if (retryDisposition == RetryDisposition.NOT_APPLICABLE
|
||||
|| (retryDisposition == RetryDisposition.RETRY_AT) != retryNotBefore.isPresent()) {
|
||||
throw new IllegalArgumentException(
|
||||
"definite non-application requires an explicit consistent disposition");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record Indeterminate(NotificationReasonCode reasonCode) implements ClientAttemptResult {
|
||||
|
||||
public Indeterminate {
|
||||
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record ClientReconciliationResult(
|
||||
SubmissionCertainty submissionCertainty, NotificationReasonCode reasonCode) {
|
||||
|
||||
public ClientReconciliationResult {
|
||||
Objects.requireNonNull(submissionCertainty, "submission certainty must be non-null");
|
||||
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicit provider-proven invalid recipient/content detected before wire I/O. */
|
||||
final class PreWireDeliveryRejectedException extends RuntimeException {
|
||||
|
||||
public PreWireDeliveryRejectedException() {
|
||||
super("provider rejected the prepared delivery before wire I/O");
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile;
|
||||
import dev.caskeleton.application.notification.NotificationReasonCode;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Provider-local bounded quota/rate admission checked before a wire attempt. */
|
||||
@FunctionalInterface
|
||||
public interface NotificationProviderRateAdmission {
|
||||
|
||||
Decision admit(NotificationProviderRuntimeProfile profile, Instant absoluteDeadline);
|
||||
|
||||
record Decision(
|
||||
boolean admitted, Optional<Instant> retryNotBefore, NotificationReasonCode reasonCode) {
|
||||
|
||||
public Decision {
|
||||
Objects.requireNonNull(retryNotBefore, "retry-not-before container must be non-null");
|
||||
Objects.requireNonNull(reasonCode, "rate admission reason code must be non-null");
|
||||
if (admitted == retryNotBefore.isPresent()) {
|
||||
throw new IllegalArgumentException(
|
||||
"admitted rate decision must not have retry time and rejection must have one");
|
||||
}
|
||||
}
|
||||
|
||||
public static Decision admitted(NotificationReasonCode reasonCode) {
|
||||
return new Decision(true, Optional.empty(), reasonCode);
|
||||
}
|
||||
|
||||
public static Decision retryAt(Instant retryAt, NotificationReasonCode reasonCode) {
|
||||
return new Decision(false, Optional.of(retryAt), reasonCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile;
|
||||
import java.time.Instant;
|
||||
|
||||
/** Bounded control-plane probe that returns non-secret provider identity and capability facts. */
|
||||
@FunctionalInterface
|
||||
public interface NotificationProviderReadinessProbe {
|
||||
|
||||
NotificationProviderReadinessSnapshot probe(
|
||||
NotificationProviderRuntimeProfile profile, Instant absoluteDeadline);
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationReasonCode;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Bounded, non-secret, exact provider readiness observation. */
|
||||
public record NotificationProviderReadinessSnapshot(
|
||||
String profileId,
|
||||
String bindingRevision,
|
||||
String capabilityCardId,
|
||||
String credentialGeneration,
|
||||
boolean ready,
|
||||
NotificationReasonCode reasonCode,
|
||||
Instant observedAt,
|
||||
Instant expiresAt) {
|
||||
|
||||
public NotificationProviderReadinessSnapshot {
|
||||
profileId = requireSlug("provider profile ID", profileId);
|
||||
bindingRevision = requireSlug("provider binding revision", bindingRevision);
|
||||
capabilityCardId = requireSlug("provider capability card ID", capabilityCardId);
|
||||
credentialGeneration = requireSlug("provider credential generation", credentialGeneration);
|
||||
Objects.requireNonNull(reasonCode, "provider readiness reason code must be non-null");
|
||||
Objects.requireNonNull(observedAt, "provider readiness observation time must be non-null");
|
||||
Objects.requireNonNull(expiresAt, "provider readiness expiry time must be non-null");
|
||||
Duration validity = Duration.between(observedAt, expiresAt);
|
||||
if (validity.isZero()
|
||||
|| validity.isNegative()
|
||||
|| validity.compareTo(Duration.ofMinutes(5)) > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"provider readiness validity must be positive and at most five minutes");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireSlug(String field, String value) {
|
||||
if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) {
|
||||
throw new IllegalArgumentException(field + " must match [a-z][a-z0-9.-]{0,62}");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile;
|
||||
|
||||
/** Acquires one operation-scoped mutable secret copy for a runtime profile. */
|
||||
@FunctionalInterface
|
||||
public interface NotificationProviderSecretMaterialProvider {
|
||||
|
||||
NotificationSecretMaterialHandle acquire(NotificationProviderRuntimeProfile profile);
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding;
|
||||
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile;
|
||||
import dev.caskeleton.application.notification.NotificationDeliveryStorePort;
|
||||
import dev.caskeleton.application.notification.NotificationReasonCode;
|
||||
import dev.caskeleton.application.notification.NotificationReconciliationPort;
|
||||
import dev.caskeleton.application.notification.SubmissionCertainty;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** Maps retained lookup identities to bounded provider reconciliation calls. */
|
||||
public final class NotificationReconciliationAdapter implements NotificationReconciliationPort {
|
||||
|
||||
private final Function<NotificationDeliveryStorePort.ReconciliationClaim, ReconciliationBinding>
|
||||
bindings;
|
||||
private final NotificationProviderSecretMaterialProvider secrets;
|
||||
private final Clock clock;
|
||||
|
||||
public NotificationReconciliationAdapter(
|
||||
Function<NotificationDeliveryStorePort.ReconciliationClaim, ReconciliationBinding> bindings,
|
||||
NotificationProviderSecretMaterialProvider secrets,
|
||||
Clock clock) {
|
||||
this.bindings =
|
||||
Objects.requireNonNull(bindings, "reconciliation binding resolver must be non-null");
|
||||
this.secrets =
|
||||
Objects.requireNonNull(secrets, "reconciliation secret provider must be non-null");
|
||||
this.clock = Objects.requireNonNull(clock, "reconciliation clock must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReconciliationOutcome reconcile(NotificationDeliveryStorePort.ReconciliationClaim claim) {
|
||||
Objects.requireNonNull(claim, "notification reconciliation claim must be non-null");
|
||||
if (!claim.absoluteDeadline().isAfter(clock.instant())) {
|
||||
return indeterminate("RECONCILIATION_DEADLINE_EXPIRED");
|
||||
}
|
||||
try {
|
||||
ReconciliationBinding binding =
|
||||
Objects.requireNonNull(bindings.apply(claim), "reconciliation binding must be non-null");
|
||||
if (!binding.matches(claim)) {
|
||||
return indeterminate("RECONCILIATION_BINDING_MISMATCH");
|
||||
}
|
||||
if (!supportsLookup(binding.target(), claim.lookupKind())) {
|
||||
return indeterminate("RECONCILIATION_UNSUPPORTED");
|
||||
}
|
||||
NotificationProviderAttemptClient.ReconciliationReference reference = reference(claim);
|
||||
ReconciliationLookupMode mode = mode(claim.lookupKind());
|
||||
NotificationProviderRuntimeProfile profile = binding.target().runtimeProfile();
|
||||
try (NotificationSecretMaterialHandle secret = secrets.acquire(profile)) {
|
||||
if (!secret.revision().equals(profile.credentialGeneration())) {
|
||||
return indeterminate("RECONCILIATION_SECRET_GENERATION_MISMATCH");
|
||||
}
|
||||
if (!claim.absoluteDeadline().isAfter(clock.instant())) {
|
||||
return indeterminate("RECONCILIATION_DEADLINE_EXPIRED");
|
||||
}
|
||||
NotificationProviderAttemptClient.ClientReconciliationResult result =
|
||||
Objects.requireNonNull(
|
||||
binding.client().reconcile(reference, mode, secret, claim.absoluteDeadline()),
|
||||
"provider reconciliation result must be non-null");
|
||||
if (result.submissionCertainty() == SubmissionCertainty.DEFINITELY_NOT_APPLIED) {
|
||||
return indeterminate("RECONCILIATION_NONAPPLICATION_UNPROVEN");
|
||||
}
|
||||
return new ReconciliationOutcome(result.submissionCertainty(), result.reasonCode());
|
||||
}
|
||||
} catch (RuntimeException providerFailure) {
|
||||
return indeterminate("RECONCILIATION_INDETERMINATE");
|
||||
}
|
||||
}
|
||||
|
||||
private static NotificationProviderAttemptClient.ReconciliationReference reference(
|
||||
NotificationDeliveryStorePort.ReconciliationClaim claim) {
|
||||
return switch (claim.lookupKind()) {
|
||||
case PRE_SEND_CORRELATION -> new AttemptCorrelationId(claim.lookupReference());
|
||||
case CLIENT_OPERATION_KEY ->
|
||||
new NotificationAttemptContext.ProviderClientOperationKey(claim.lookupReference());
|
||||
case MESSAGE_REFERENCE -> new ProviderMessageReference(claim.lookupReference());
|
||||
};
|
||||
}
|
||||
|
||||
private static ReconciliationLookupMode mode(
|
||||
NotificationDeliveryStorePort.ReconciliationLookupKind kind) {
|
||||
return ReconciliationLookupMode.valueOf(kind.name());
|
||||
}
|
||||
|
||||
private static boolean supportsLookup(
|
||||
CompiledNotificationBinding.CompiledTarget target,
|
||||
NotificationDeliveryStorePort.ReconciliationLookupKind lookupKind) {
|
||||
return target.capabilityCard().reconciliationSupported()
|
||||
&& target.capabilityCard().maximumReconcileCalls() > 0
|
||||
&& target.capabilityCard().cardId().equals("aws-ses-v2-durable-single-local-sns-v1")
|
||||
&& lookupKind
|
||||
== NotificationDeliveryStorePort.ReconciliationLookupKind.PRE_SEND_CORRELATION;
|
||||
}
|
||||
|
||||
private static ReconciliationOutcome indeterminate(String reason) {
|
||||
return new ReconciliationOutcome(
|
||||
SubmissionCertainty.INDETERMINATE, new NotificationReasonCode(reason));
|
||||
}
|
||||
|
||||
public static final class ReconciliationBinding {
|
||||
|
||||
private final CompiledNotificationBinding binding;
|
||||
private final int targetOrdinal;
|
||||
private final NotificationProviderAttemptClient client;
|
||||
private final CompiledNotificationBinding.CompiledTarget target;
|
||||
|
||||
public ReconciliationBinding(
|
||||
CompiledNotificationBinding binding,
|
||||
int targetOrdinal,
|
||||
NotificationProviderAttemptClient client) {
|
||||
this.binding =
|
||||
Objects.requireNonNull(binding, "reconciliation compiled binding must be non-null");
|
||||
if (targetOrdinal < 0 || targetOrdinal >= binding.targets().size()) {
|
||||
throw new IllegalArgumentException(
|
||||
"reconciliation target ordinal is outside the compiled binding");
|
||||
}
|
||||
this.targetOrdinal = targetOrdinal;
|
||||
this.client = Objects.requireNonNull(client, "reconciliation client must be non-null");
|
||||
this.target = binding.targets().get(targetOrdinal);
|
||||
}
|
||||
|
||||
public NotificationProviderAttemptClient client() {
|
||||
return client;
|
||||
}
|
||||
|
||||
public CompiledNotificationBinding.CompiledTarget target() {
|
||||
return target;
|
||||
}
|
||||
|
||||
private boolean matches(NotificationDeliveryStorePort.ReconciliationClaim claim) {
|
||||
return binding.route().routeId().equals(claim.routeId())
|
||||
&& binding.route().routeRevision() == claim.routeRevision()
|
||||
&& binding.bindingDigest().equals(claim.bindingDigest())
|
||||
&& targetOrdinal == claim.targetOrdinal()
|
||||
&& target.target().targetId().equals(claim.targetReference())
|
||||
&& target.capabilityCard().cardId().equals(claim.providerCapabilityReference())
|
||||
&& target.runtimeProfile().bindingRevision().equals(claim.providerBindingRevision())
|
||||
&& target.runtimeProfile().credentialGeneration().equals(claim.credentialGeneration());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ReconciliationBinding[bindingDigest="
|
||||
+ binding.bindingDigest()
|
||||
+ ", targetOrdinal="
|
||||
+ targetOrdinal
|
||||
+ ", client=<redacted>, target=<redacted>]";
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** Versioned operation-scoped mutable secret copy that wipes itself on close. */
|
||||
public final class NotificationSecretMaterialHandle implements AutoCloseable {
|
||||
|
||||
private final String revision;
|
||||
private byte[] bytes;
|
||||
private char[] characters;
|
||||
private boolean closed;
|
||||
|
||||
private NotificationSecretMaterialHandle(String revision, byte[] bytes, char[] characters) {
|
||||
this.revision = requireSlug(revision);
|
||||
this.bytes = bytes;
|
||||
this.characters = characters;
|
||||
}
|
||||
|
||||
public static NotificationSecretMaterialHandle fromBytes(String revision, byte[] value) {
|
||||
Objects.requireNonNull(value, "secret byte material must be non-null");
|
||||
if (value.length < 1 || value.length > 65_536) {
|
||||
throw new IllegalArgumentException("secret byte material must contain 1..65536 bytes");
|
||||
}
|
||||
return new NotificationSecretMaterialHandle(revision, value.clone(), null);
|
||||
}
|
||||
|
||||
public static NotificationSecretMaterialHandle fromCharacters(String revision, char[] value) {
|
||||
Objects.requireNonNull(value, "secret character material must be non-null");
|
||||
if (value.length < 1 || value.length > 65_536) {
|
||||
throw new IllegalArgumentException("secret character material must contain 1..65536 values");
|
||||
}
|
||||
return new NotificationSecretMaterialHandle(revision, null, value.clone());
|
||||
}
|
||||
|
||||
public String revision() {
|
||||
return revision;
|
||||
}
|
||||
|
||||
public synchronized <T> T readBytes(Function<byte[], T> reader) {
|
||||
Objects.requireNonNull(reader, "secret byte reader must be non-null");
|
||||
requireOpen();
|
||||
if (bytes == null) {
|
||||
throw new IllegalStateException("secret handle does not contain byte material");
|
||||
}
|
||||
return reader.apply(bytes);
|
||||
}
|
||||
|
||||
public synchronized <T> T readCharacters(Function<char[], T> reader) {
|
||||
Objects.requireNonNull(reader, "secret character reader must be non-null");
|
||||
requireOpen();
|
||||
if (characters == null) {
|
||||
throw new IllegalStateException("secret handle does not contain character material");
|
||||
}
|
||||
return reader.apply(characters);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
if (!closed) {
|
||||
if (bytes != null) {
|
||||
Arrays.fill(bytes, (byte) 0);
|
||||
}
|
||||
if (characters != null) {
|
||||
Arrays.fill(characters, '\0');
|
||||
}
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "NotificationSecretMaterialHandle[revision="
|
||||
+ revision
|
||||
+ ", material=<redacted>, closed="
|
||||
+ closed
|
||||
+ "]";
|
||||
}
|
||||
|
||||
private void requireOpen() {
|
||||
if (closed) {
|
||||
throw new IllegalStateException("secret material handle is closed");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireSlug(String value) {
|
||||
if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) {
|
||||
throw new IllegalArgumentException("secret revision must match [a-z][a-z0-9.-]{0,62}");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding;
|
||||
import dev.caskeleton.adapter.outbound.notification.template.RenderedNotification;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Side-effect-free provider preparation result for exactly one compiled target. */
|
||||
public record PreparedNotificationAttempt(
|
||||
RenderedNotification rendered,
|
||||
CompiledNotificationBinding.CompiledTarget target,
|
||||
NotificationAttemptContext context,
|
||||
String payloadDigest) {
|
||||
|
||||
public PreparedNotificationAttempt {
|
||||
Objects.requireNonNull(rendered, "rendered notification must be non-null");
|
||||
Objects.requireNonNull(target, "compiled notification target must be non-null");
|
||||
Objects.requireNonNull(context, "notification attempt context must be non-null");
|
||||
if (payloadDigest == null || !payloadDigest.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException("prepared payload digest must be lowercase SHA-256");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "PreparedNotificationAttempt[target="
|
||||
+ target.target().targetId()
|
||||
+ ", rendered=<redacted>, context=<redacted>, payloadDigest="
|
||||
+ payloadDigest
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
/** Opaque provider message identity available only after an accepted response or receipt. */
|
||||
public record ProviderMessageReference(String value)
|
||||
implements NotificationProviderAttemptClient.ReconciliationReference {
|
||||
|
||||
public ProviderMessageReference {
|
||||
value = AttemptCorrelationId.requireOpaque("provider message reference", value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ProviderMessageReference[value=<redacted>]";
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
/** Provider-documented lookup identity available for reconciliation. */
|
||||
public enum ReconciliationLookupMode {
|
||||
PRE_SEND_CORRELATION,
|
||||
CLIENT_OPERATION_KEY,
|
||||
MESSAGE_REFERENCE,
|
||||
UNSUPPORTED
|
||||
}
|
||||
+473
@@ -0,0 +1,473 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.template;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationChannel;
|
||||
import dev.caskeleton.application.notification.NotificationFrozenPlan;
|
||||
import dev.caskeleton.application.notification.NotificationTemplateValue;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Context-aware local email renderer for reviewed subject, text and HTML resources. */
|
||||
public final class LocalEmailRenderer
|
||||
implements NotificationTemplateRenderer<RenderedNotification.Email> {
|
||||
|
||||
private static final Pattern TOKEN =
|
||||
Pattern.compile("\\{\\{([a-z][A-Za-z0-9]{0,63})\\|([a-z_]+)}}");
|
||||
private static final Pattern HTML_NAME = Pattern.compile("[a-z][a-z0-9-]{0,31}");
|
||||
private static final java.util.Set<String> APPROVED_HTML_TAGS =
|
||||
java.util.Set.of(
|
||||
"a", "b", "body", "br", "div", "em", "h1", "h2", "h3", "h4", "h5", "h6", "head", "html",
|
||||
"i", "img", "li", "ol", "p", "span", "strong", "table", "tbody", "td", "th", "thead",
|
||||
"tr", "ul");
|
||||
private static final java.util.Set<String> APPROVED_INERT_ATTRIBUTES =
|
||||
java.util.Set.of("alt", "aria-label", "title");
|
||||
|
||||
private final NotificationTemplateCatalog catalog;
|
||||
|
||||
public LocalEmailRenderer(NotificationTemplateCatalog catalog) {
|
||||
this.catalog = Objects.requireNonNull(catalog, "template catalog must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public RenderedNotification.Email render(NotificationFrozenPlan plan) {
|
||||
Objects.requireNonNull(plan, "notification frozen plan must be non-null");
|
||||
NotificationTemplateManifest manifest = catalog.require(plan.policy().templateRef());
|
||||
validatePlan(plan, manifest, NotificationChannel.EMAIL);
|
||||
NotificationTemplateCatalog.LoadedResourceSet sources =
|
||||
catalog.sources(manifest, plan.selectedLocale());
|
||||
String subjectTemplate = sources.subject().orElseThrow();
|
||||
String textTemplate = sources.text().orElseThrow();
|
||||
String htmlTemplate = sources.html().orElseThrow();
|
||||
validateTemplateUsage(
|
||||
java.util.List.of(subjectTemplate, textTemplate, htmlTemplate), manifest.parameterNames());
|
||||
validateContexts(subjectTemplate, java.util.Set.of("header"), "email subject");
|
||||
validateContexts(textTemplate, java.util.Set.of("text"), "email text");
|
||||
validateHtmlContexts(htmlTemplate);
|
||||
requireAggregateBoundedExpansion(
|
||||
java.util.List.of(subjectTemplate, textTemplate, htmlTemplate),
|
||||
plan.parameters().values(),
|
||||
manifest.maximumRenderedBytes(),
|
||||
12);
|
||||
String subject =
|
||||
stripOneTrailingLineBreak(
|
||||
render(
|
||||
subjectTemplate,
|
||||
plan.parameters().values(),
|
||||
manifest.parameterNames(),
|
||||
manifest.maximumRenderedBytes()));
|
||||
if (subject.indexOf('\r') >= 0 || subject.indexOf('\n') >= 0) {
|
||||
throw new TemplateRenderingException("rendered email header contains a line break");
|
||||
}
|
||||
String text =
|
||||
render(
|
||||
textTemplate,
|
||||
plan.parameters().values(),
|
||||
manifest.parameterNames(),
|
||||
manifest.maximumRenderedBytes());
|
||||
String html =
|
||||
render(
|
||||
htmlTemplate,
|
||||
plan.parameters().values(),
|
||||
manifest.parameterNames(),
|
||||
manifest.maximumRenderedBytes());
|
||||
int bytes =
|
||||
subject.getBytes(StandardCharsets.UTF_8).length
|
||||
+ text.getBytes(StandardCharsets.UTF_8).length
|
||||
+ html.getBytes(StandardCharsets.UTF_8).length;
|
||||
if (bytes > manifest.maximumRenderedBytes()) {
|
||||
throw new TemplateRenderingException("rendered email exceeds the manifest byte bound");
|
||||
}
|
||||
return new RenderedNotification.Email(sources.locale(), subject, text, html, bytes);
|
||||
}
|
||||
|
||||
static void validatePlan(
|
||||
NotificationFrozenPlan plan,
|
||||
NotificationTemplateManifest manifest,
|
||||
NotificationChannel expectedChannel) {
|
||||
if (plan.policy().channel() != expectedChannel || manifest.channel() != expectedChannel) {
|
||||
throw new TemplateRenderingException("template channel does not match renderer");
|
||||
}
|
||||
if (!plan.binding().templateChecksum().equals(manifest.checksum())) {
|
||||
throw new TemplateRenderingException("frozen template checksum does not match manifest");
|
||||
}
|
||||
if (!plan.binding().rendererRevision().equals(manifest.rendererRevision())) {
|
||||
throw new TemplateRenderingException("frozen renderer revision does not match manifest");
|
||||
}
|
||||
if (!plan.parameters().values().keySet().equals(manifest.parameterNames())) {
|
||||
throw new TemplateRenderingException(
|
||||
"template parameter names must exactly match the manifest");
|
||||
}
|
||||
}
|
||||
|
||||
static String scalar(NotificationTemplateValue value) {
|
||||
return switch (value) {
|
||||
case NotificationTemplateValue.SafeText safeText -> safeText.value();
|
||||
case NotificationTemplateValue.TrustedAbsoluteLinkReference link -> link.value();
|
||||
case NotificationTemplateValue.LocalDateValue date -> date.value().toString();
|
||||
case NotificationTemplateValue.LocalDateTimeValue dateTime ->
|
||||
dateTime.value().atZone(dateTime.zone()).toString();
|
||||
case NotificationTemplateValue.IntegerValue integer -> Long.toString(integer.value());
|
||||
case NotificationTemplateValue.MoneyValue money ->
|
||||
money.amount().toPlainString() + " " + money.currency().getCurrencyCode();
|
||||
};
|
||||
}
|
||||
|
||||
static String html(String value) {
|
||||
return value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
static String urlComponent(String value) {
|
||||
StringBuilder encoded = new StringBuilder();
|
||||
for (byte item : value.getBytes(StandardCharsets.UTF_8)) {
|
||||
int unsigned = item & 0xff;
|
||||
char character = (char) unsigned;
|
||||
if ((character >= 'a' && character <= 'z')
|
||||
|| (character >= 'A' && character <= 'Z')
|
||||
|| (character >= '0' && character <= '9')
|
||||
|| character == '-'
|
||||
|| character == '.'
|
||||
|| character == '_'
|
||||
|| character == '~') {
|
||||
encoded.append(character);
|
||||
} else {
|
||||
encoded.append('%');
|
||||
encoded.append(Character.toUpperCase(Character.forDigit((unsigned >>> 4) & 0xf, 16)));
|
||||
encoded.append(Character.toUpperCase(Character.forDigit(unsigned & 0xf, 16)));
|
||||
}
|
||||
}
|
||||
return encoded.toString();
|
||||
}
|
||||
|
||||
static void validateTemplateUsage(
|
||||
java.util.List<String> templates, java.util.Set<String> manifestNames) {
|
||||
java.util.Set<String> used = new java.util.HashSet<>();
|
||||
templates.forEach(
|
||||
template -> {
|
||||
Matcher matcher = TOKEN.matcher(template);
|
||||
while (matcher.find()) {
|
||||
used.add(matcher.group(1));
|
||||
}
|
||||
});
|
||||
if (!used.equals(manifestNames)) {
|
||||
throw new TemplateRenderingException(
|
||||
"template parameter usage must exactly match the manifest");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateContexts(
|
||||
String template, java.util.Set<String> allowedContexts, String assetRole) {
|
||||
Matcher matcher = TOKEN.matcher(template);
|
||||
while (matcher.find()) {
|
||||
if (!allowedContexts.contains(matcher.group(2))) {
|
||||
throw new TemplateRenderingException(
|
||||
assetRole + " uses a rendering context that is not approved for that asset");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void validateHtmlContexts(String template) {
|
||||
validateRestrictedHtml(template);
|
||||
Matcher matcher = TOKEN.matcher(template);
|
||||
while (matcher.find()) {
|
||||
boolean insideTag = isInsideMarkupTag(template, matcher.start());
|
||||
switch (matcher.group(2)) {
|
||||
case "html_text" -> {
|
||||
if (insideTag) {
|
||||
throw new TemplateRenderingException(
|
||||
"HTML text parameter must occur outside markup tags");
|
||||
}
|
||||
}
|
||||
case "html_attr" -> {
|
||||
if (!insideTag || !isApprovedHtmlAttribute(template, matcher)) {
|
||||
throw new TemplateRenderingException(
|
||||
"HTML attribute parameter is outside an approved inert attribute");
|
||||
}
|
||||
}
|
||||
case "url_component" -> {
|
||||
if (!insideTag || !isApprovedUrlComponent(template, matcher)) {
|
||||
throw new TemplateRenderingException(
|
||||
"URL component parameter is outside an approved HTTPS link attribute");
|
||||
}
|
||||
}
|
||||
default ->
|
||||
throw new TemplateRenderingException(
|
||||
"email HTML uses a rendering context that is not approved for that asset");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String render(
|
||||
String template,
|
||||
Map<String, NotificationTemplateValue> parameters,
|
||||
java.util.Set<String> manifestNames,
|
||||
int maximumBytes) {
|
||||
requireBoundedExpansion(template, parameters, maximumBytes);
|
||||
Matcher matcher = TOKEN.matcher(template);
|
||||
StringBuilder output = new StringBuilder(Math.min(template.length(), maximumBytes));
|
||||
while (matcher.find()) {
|
||||
String name = matcher.group(1);
|
||||
NotificationTemplateValue parameter = parameters.get(name);
|
||||
if (parameter == null || !manifestNames.contains(name)) {
|
||||
throw new TemplateRenderingException("template references an unknown parameter");
|
||||
}
|
||||
String raw = scalar(parameter);
|
||||
String replacement =
|
||||
switch (matcher.group(2)) {
|
||||
case "header" -> {
|
||||
if (raw.indexOf('\r') >= 0 || raw.indexOf('\n') >= 0) {
|
||||
throw new TemplateRenderingException(
|
||||
"rendered email header parameter contains a line break");
|
||||
}
|
||||
yield raw;
|
||||
}
|
||||
case "text" -> raw;
|
||||
case "html_text" -> html(raw);
|
||||
case "html_attr" -> htmlAttribute(template, matcher, raw);
|
||||
case "url_component" -> {
|
||||
if (!(parameter instanceof NotificationTemplateValue.SafeText)) {
|
||||
throw new TemplateRenderingException(
|
||||
"URL component parameters must use bounded safe text values");
|
||||
}
|
||||
yield urlComponent(raw);
|
||||
}
|
||||
default -> throw new TemplateRenderingException("unknown email rendering context");
|
||||
};
|
||||
matcher.appendReplacement(output, Matcher.quoteReplacement(replacement));
|
||||
}
|
||||
matcher.appendTail(output);
|
||||
if (output.indexOf("{{") >= 0) {
|
||||
throw new TemplateRenderingException("template has an unresolved parameter");
|
||||
}
|
||||
return output.toString();
|
||||
}
|
||||
|
||||
private static void requireBoundedExpansion(
|
||||
String template, Map<String, NotificationTemplateValue> parameters, int maximumBytes) {
|
||||
requireAggregateBoundedExpansion(java.util.List.of(template), parameters, maximumBytes, 12);
|
||||
}
|
||||
|
||||
static void requireAggregateBoundedExpansion(
|
||||
java.util.List<String> templates,
|
||||
Map<String, NotificationTemplateValue> parameters,
|
||||
int maximumBytes,
|
||||
int maximumEscapedBytesPerInputByte) {
|
||||
long projectedBytes = 0;
|
||||
for (String template : templates) {
|
||||
projectedBytes =
|
||||
Math.addExact(projectedBytes, template.getBytes(StandardCharsets.UTF_8).length);
|
||||
Matcher matcher = TOKEN.matcher(template);
|
||||
while (matcher.find()) {
|
||||
NotificationTemplateValue value = parameters.get(matcher.group(1));
|
||||
if (value != null) {
|
||||
long rawBytes = scalar(value).getBytes(StandardCharsets.UTF_8).length;
|
||||
projectedBytes =
|
||||
Math.addExact(
|
||||
projectedBytes,
|
||||
Math.multiplyExact(rawBytes, (long) maximumEscapedBytesPerInputByte));
|
||||
}
|
||||
if (projectedBytes > maximumBytes) {
|
||||
throw new TemplateRenderingException(
|
||||
"rendered template expansion exceeds the manifest byte bound");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String htmlAttribute(String template, Matcher matcher, String raw) {
|
||||
if (!isApprovedHtmlAttribute(template, matcher)
|
||||
|| raw.indexOf('`') >= 0
|
||||
|| raw.chars().anyMatch(Character::isISOControl)) {
|
||||
throw new TemplateRenderingException(
|
||||
"HTML attribute parameter is outside an approved quoted non-URL context");
|
||||
}
|
||||
return html(raw);
|
||||
}
|
||||
|
||||
private static boolean isApprovedHtmlAttribute(String template, Matcher matcher) {
|
||||
boolean exactlyQuoted =
|
||||
matcher.start() > 0
|
||||
&& matcher.end() < template.length()
|
||||
&& template.charAt(matcher.start() - 1) == '"'
|
||||
&& template.charAt(matcher.end()) == '"';
|
||||
String prefix = template.substring(Math.max(0, matcher.start() - 64), matcher.start());
|
||||
return exactlyQuoted
|
||||
&& prefix
|
||||
.toLowerCase(java.util.Locale.ROOT)
|
||||
.matches("(?s).*(title|alt|aria-label)\\s*=\\s*\"$");
|
||||
}
|
||||
|
||||
private static boolean isApprovedUrlComponent(String template, Matcher matcher) {
|
||||
int tagStart = template.lastIndexOf('<', matcher.start());
|
||||
int quoteEnd = template.indexOf('"', matcher.end());
|
||||
int tagEnd = template.indexOf('>', matcher.end());
|
||||
if (tagStart < 0 || quoteEnd < matcher.end() || tagEnd < quoteEnd) {
|
||||
return false;
|
||||
}
|
||||
String attributePrefix =
|
||||
template.substring(tagStart, matcher.start()).toLowerCase(java.util.Locale.ROOT);
|
||||
String attributeSuffix = template.substring(matcher.end(), quoteEnd);
|
||||
return attributePrefix.matches(
|
||||
"(?s).*\\shref\\s*=\\s*\"https://"
|
||||
+ "[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?(?::[0-9]{1,5})?"
|
||||
+ "(?:/[^\"{}]*|\\?[^\"{}]*|#[^\"{}]*)$")
|
||||
&& !attributeSuffix.contains("{{")
|
||||
&& !attributeSuffix.contains("}}");
|
||||
}
|
||||
|
||||
private static boolean isInsideMarkupTag(String template, int position) {
|
||||
boolean insideTag = false;
|
||||
char quote = 0;
|
||||
for (int index = 0; index < position; index++) {
|
||||
char current = template.charAt(index);
|
||||
if (!insideTag) {
|
||||
if (current == '<') {
|
||||
insideTag = true;
|
||||
}
|
||||
} else if (quote != 0) {
|
||||
if (current == quote) {
|
||||
quote = 0;
|
||||
}
|
||||
} else if (current == '"' || current == '\'') {
|
||||
quote = current;
|
||||
} else if (current == '>') {
|
||||
insideTag = false;
|
||||
}
|
||||
}
|
||||
return insideTag;
|
||||
}
|
||||
|
||||
private static void validateRestrictedHtml(String template) {
|
||||
ArrayDeque<String> openTags = new ArrayDeque<>();
|
||||
int cursor = 0;
|
||||
while (cursor < template.length()) {
|
||||
int tagStart = template.indexOf('<', cursor);
|
||||
if (tagStart < 0) {
|
||||
break;
|
||||
}
|
||||
int tagEnd = findTagEnd(template, tagStart + 1);
|
||||
if (tagEnd < 0) {
|
||||
throw new TemplateRenderingException("email HTML contains an unterminated markup tag");
|
||||
}
|
||||
String body = template.substring(tagStart + 1, tagEnd).trim();
|
||||
if (body.isEmpty() || body.startsWith("!") || body.startsWith("?")) {
|
||||
throw new TemplateRenderingException("email HTML contains unsupported markup");
|
||||
}
|
||||
if (body.startsWith("/")) {
|
||||
String closingName = body.substring(1).trim().toLowerCase(java.util.Locale.ROOT);
|
||||
if (!HTML_NAME.matcher(closingName).matches()
|
||||
|| openTags.isEmpty()
|
||||
|| !openTags.removeLast().equals(closingName)) {
|
||||
throw new TemplateRenderingException("email HTML contains mismatched markup");
|
||||
}
|
||||
} else {
|
||||
boolean selfClosing = body.endsWith("/");
|
||||
String opening = selfClosing ? body.substring(0, body.length() - 1).trim() : body;
|
||||
int nameEnd = 0;
|
||||
while (nameEnd < opening.length()
|
||||
&& (Character.isLetterOrDigit(opening.charAt(nameEnd))
|
||||
|| opening.charAt(nameEnd) == '-')) {
|
||||
nameEnd++;
|
||||
}
|
||||
String tagName = opening.substring(0, nameEnd).toLowerCase(java.util.Locale.ROOT);
|
||||
if (!HTML_NAME.matcher(tagName).matches() || !APPROVED_HTML_TAGS.contains(tagName)) {
|
||||
throw new TemplateRenderingException("email HTML contains an unsupported tag");
|
||||
}
|
||||
validateAttributes(tagName, opening.substring(nameEnd));
|
||||
boolean voidElement = tagName.equals("br") || tagName.equals("img");
|
||||
if (selfClosing != voidElement && (selfClosing || voidElement)) {
|
||||
throw new TemplateRenderingException("email HTML uses an invalid void element shape");
|
||||
}
|
||||
if (!voidElement) {
|
||||
openTags.addLast(tagName);
|
||||
}
|
||||
}
|
||||
cursor = tagEnd + 1;
|
||||
}
|
||||
if (!openTags.isEmpty()) {
|
||||
throw new TemplateRenderingException("email HTML contains unclosed markup");
|
||||
}
|
||||
}
|
||||
|
||||
private static int findTagEnd(String template, int start) {
|
||||
char quote = 0;
|
||||
for (int index = start; index < template.length(); index++) {
|
||||
char current = template.charAt(index);
|
||||
if (quote != 0) {
|
||||
if (current == quote) {
|
||||
quote = 0;
|
||||
}
|
||||
} else if (current == '"' || current == '\'') {
|
||||
quote = current;
|
||||
} else if (current == '>') {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static void validateAttributes(String tagName, String attributes) {
|
||||
int cursor = 0;
|
||||
while (cursor < attributes.length()) {
|
||||
while (cursor < attributes.length() && Character.isWhitespace(attributes.charAt(cursor))) {
|
||||
cursor++;
|
||||
}
|
||||
if (cursor == attributes.length()) {
|
||||
return;
|
||||
}
|
||||
int nameStart = cursor;
|
||||
while (cursor < attributes.length()
|
||||
&& (Character.isLetterOrDigit(attributes.charAt(cursor))
|
||||
|| attributes.charAt(cursor) == '-')) {
|
||||
cursor++;
|
||||
}
|
||||
String attributeName =
|
||||
attributes.substring(nameStart, cursor).toLowerCase(java.util.Locale.ROOT);
|
||||
if (!HTML_NAME.matcher(attributeName).matches()) {
|
||||
throw new TemplateRenderingException("email HTML contains malformed attributes");
|
||||
}
|
||||
while (cursor < attributes.length() && Character.isWhitespace(attributes.charAt(cursor))) {
|
||||
cursor++;
|
||||
}
|
||||
if (cursor >= attributes.length() || attributes.charAt(cursor++) != '=') {
|
||||
throw new TemplateRenderingException("email HTML attributes must have explicit values");
|
||||
}
|
||||
while (cursor < attributes.length() && Character.isWhitespace(attributes.charAt(cursor))) {
|
||||
cursor++;
|
||||
}
|
||||
if (cursor >= attributes.length() || attributes.charAt(cursor++) != '"') {
|
||||
throw new TemplateRenderingException("email HTML attributes must be double quoted");
|
||||
}
|
||||
int valueEnd = attributes.indexOf('"', cursor);
|
||||
if (valueEnd < 0) {
|
||||
throw new TemplateRenderingException("email HTML contains an unterminated attribute");
|
||||
}
|
||||
String value = attributes.substring(cursor, valueEnd);
|
||||
if (attributeName.equals("href")) {
|
||||
if (!tagName.equals("a") || !value.startsWith("https://")) {
|
||||
throw new TemplateRenderingException("email HTML links must be static HTTPS links");
|
||||
}
|
||||
} else if (!APPROVED_INERT_ATTRIBUTES.contains(attributeName)) {
|
||||
throw new TemplateRenderingException("email HTML contains an unsupported attribute");
|
||||
}
|
||||
cursor = valueEnd + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static String stripOneTrailingLineBreak(String value) {
|
||||
if (value.endsWith("\r\n")) {
|
||||
return value.substring(0, value.length() - 2);
|
||||
}
|
||||
if (value.endsWith("\n")) {
|
||||
return value.substring(0, value.length() - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.template;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationTemplateRef;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Loads only manifest-named classpath assets, validates exact checksums, and retains immutable
|
||||
* text.
|
||||
*/
|
||||
public final class NotificationTemplateCatalog {
|
||||
|
||||
private static final int MAXIMUM_RESOURCE_BYTES = 1_000_000;
|
||||
private static final long MAXIMUM_CATALOG_BYTES = 64L * 1024L * 1024L;
|
||||
private static final int MAXIMUM_CATALOG_RESOURCES = 4_096;
|
||||
private static final long MAXIMUM_MANIFEST_BYTES = 16L * 1024L * 1024L;
|
||||
private static final int MAXIMUM_MANIFEST_RESOURCES = 256;
|
||||
|
||||
private final Map<NotificationTemplateRef, LoadedTemplate> templates;
|
||||
|
||||
public NotificationTemplateCatalog(
|
||||
ClassLoader classLoader, List<NotificationTemplateManifest> manifests) {
|
||||
Objects.requireNonNull(classLoader, "template class loader must be non-null");
|
||||
Objects.requireNonNull(manifests, "template manifests must be non-null");
|
||||
Map<NotificationTemplateRef, LoadedTemplate> loaded = new HashMap<>();
|
||||
LoadBudget budget = new LoadBudget(MAXIMUM_CATALOG_BYTES, MAXIMUM_CATALOG_RESOURCES);
|
||||
manifests.stream()
|
||||
.sorted(
|
||||
Comparator.comparing(
|
||||
(NotificationTemplateManifest manifest) -> manifest.templateRef().templateId())
|
||||
.thenComparingInt(manifest -> manifest.templateRef().version()))
|
||||
.forEach(
|
||||
manifest -> {
|
||||
Objects.requireNonNull(manifest, "template manifest must be non-null");
|
||||
if (loaded.containsKey(manifest.templateRef())) {
|
||||
throw new TemplateRenderingException("duplicate template manifest");
|
||||
}
|
||||
Map<Locale, LoadedResourceSet> sources =
|
||||
load(classLoader, manifest.resources(), budget);
|
||||
String actual = checksumLoaded(sources);
|
||||
if (!actual.equals(manifest.checksum())) {
|
||||
throw new TemplateRenderingException("template manifest checksum mismatch");
|
||||
}
|
||||
sources.values().forEach(NotificationTemplateCatalog::rejectDirectives);
|
||||
loaded.put(manifest.templateRef(), new LoadedTemplate(manifest, sources));
|
||||
});
|
||||
if (loaded.isEmpty() || loaded.size() > 100) {
|
||||
throw new TemplateRenderingException("template catalog must contain 1..100 manifests");
|
||||
}
|
||||
this.templates = Map.copyOf(loaded);
|
||||
}
|
||||
|
||||
public NotificationTemplateManifest require(NotificationTemplateRef templateRef) {
|
||||
Objects.requireNonNull(templateRef, "template reference must be non-null");
|
||||
LoadedTemplate template = templates.get(templateRef);
|
||||
if (template == null) {
|
||||
throw new TemplateRenderingException("unknown template revision");
|
||||
}
|
||||
return template.manifest();
|
||||
}
|
||||
|
||||
LoadedResourceSet sources(NotificationTemplateManifest manifest, Locale requested) {
|
||||
LoadedTemplate loaded = templates.get(manifest.templateRef());
|
||||
if (loaded == null || !loaded.manifest().equals(manifest)) {
|
||||
throw new TemplateRenderingException("template manifest is outside the loaded catalog");
|
||||
}
|
||||
Objects.requireNonNull(requested, "frozen selected locale must be non-null");
|
||||
Locale selected = Locale.forLanguageTag(requested.toLanguageTag());
|
||||
if (!manifest.supportedLocales().contains(selected)) {
|
||||
throw new TemplateRenderingException(
|
||||
"frozen selected locale is outside the exact template manifest");
|
||||
}
|
||||
LoadedResourceSet sources = loaded.sources().get(selected);
|
||||
if (sources == null) {
|
||||
throw new TemplateRenderingException("selected template locale has no exact resource set");
|
||||
}
|
||||
return sources.withLocale(selected);
|
||||
}
|
||||
|
||||
public static String checksum(
|
||||
ClassLoader classLoader, Map<Locale, NotificationTemplateManifest.ResourceSet> resources) {
|
||||
Objects.requireNonNull(classLoader, "template class loader must be non-null");
|
||||
Objects.requireNonNull(resources, "template resources must be non-null");
|
||||
return checksumLoaded(
|
||||
load(
|
||||
classLoader,
|
||||
resources,
|
||||
new LoadBudget(MAXIMUM_MANIFEST_BYTES, MAXIMUM_MANIFEST_RESOURCES)));
|
||||
}
|
||||
|
||||
private static Map<Locale, LoadedResourceSet> load(
|
||||
ClassLoader classLoader,
|
||||
Map<Locale, NotificationTemplateManifest.ResourceSet> resources,
|
||||
LoadBudget budget) {
|
||||
LinkedHashMap<Locale, LoadedResourceSet> loaded = new LinkedHashMap<>();
|
||||
resources.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey(Comparator.comparing(Locale::toLanguageTag)))
|
||||
.forEach(
|
||||
entry -> {
|
||||
NotificationTemplateManifest.ResourceSet paths = entry.getValue();
|
||||
loaded.put(
|
||||
entry.getKey(),
|
||||
new LoadedResourceSet(
|
||||
entry.getKey(),
|
||||
paths.subject().map(path -> text(classLoader, path, budget)),
|
||||
paths.text().map(path -> text(classLoader, path, budget)),
|
||||
paths.html().map(path -> text(classLoader, path, budget)),
|
||||
paths.slack().map(path -> text(classLoader, path, budget))));
|
||||
});
|
||||
return Map.copyOf(loaded);
|
||||
}
|
||||
|
||||
private static String checksumLoaded(Map<Locale, LoadedResourceSet> sources) {
|
||||
MessageDigest digest = sha256();
|
||||
sources.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey(Comparator.comparing(Locale::toLanguageTag)))
|
||||
.forEach(
|
||||
entry -> {
|
||||
update(digest, entry.getKey().toLanguageTag());
|
||||
LoadedResourceSet resource = entry.getValue();
|
||||
updateOptional(digest, "subject", resource.subject());
|
||||
updateOptional(digest, "text", resource.text());
|
||||
updateOptional(digest, "html", resource.html());
|
||||
updateOptional(digest, "slack", resource.slack());
|
||||
});
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private static void updateOptional(
|
||||
MessageDigest digest, String role, java.util.Optional<String> content) {
|
||||
content.ifPresent(
|
||||
value -> {
|
||||
update(digest, role);
|
||||
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(encoded.length).array());
|
||||
digest.update(encoded);
|
||||
});
|
||||
}
|
||||
|
||||
private static void rejectDirectives(LoadedResourceSet sources) {
|
||||
java.util.stream.Stream.of(sources.subject(), sources.text(), sources.html(), sources.slack())
|
||||
.flatMap(java.util.Optional::stream)
|
||||
.forEach(
|
||||
content -> {
|
||||
if (content.contains("{{>")
|
||||
|| content.contains("{%")
|
||||
|| content.contains("${")
|
||||
|| content.contains("{{#")) {
|
||||
throw new TemplateRenderingException(
|
||||
"template contains unsupported include or reflection directive");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static String text(ClassLoader classLoader, String path, LoadBudget budget) {
|
||||
byte[] bytes = read(classLoader, path);
|
||||
budget.consume(bytes.length);
|
||||
return new String(bytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static byte[] read(ClassLoader classLoader, String path) {
|
||||
try (InputStream stream = classLoader.getResourceAsStream(path)) {
|
||||
if (stream == null) {
|
||||
throw new TemplateRenderingException("manifest resource is missing");
|
||||
}
|
||||
byte[] bytes = stream.readNBytes(MAXIMUM_RESOURCE_BYTES + 1);
|
||||
if (bytes.length > MAXIMUM_RESOURCE_BYTES) {
|
||||
throw new TemplateRenderingException("manifest resource exceeds the byte bound");
|
||||
}
|
||||
return bytes;
|
||||
} catch (IOException failure) {
|
||||
throw new TemplateRenderingException("manifest resource could not be loaded", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException unavailable) {
|
||||
throw new IllegalStateException(
|
||||
"SHA-256 must be available on every Java runtime", unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
private static void update(MessageDigest digest, String value) {
|
||||
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(encoded.length).array());
|
||||
digest.update(encoded);
|
||||
}
|
||||
|
||||
record LoadedTemplate(
|
||||
NotificationTemplateManifest manifest, Map<Locale, LoadedResourceSet> sources) {
|
||||
|
||||
LoadedTemplate {
|
||||
Objects.requireNonNull(manifest, "loaded template manifest must be non-null");
|
||||
sources = Map.copyOf(Objects.requireNonNull(sources, "loaded sources must be non-null"));
|
||||
}
|
||||
}
|
||||
|
||||
record LoadedResourceSet(
|
||||
Locale locale,
|
||||
java.util.Optional<String> subject,
|
||||
java.util.Optional<String> text,
|
||||
java.util.Optional<String> html,
|
||||
java.util.Optional<String> slack) {
|
||||
|
||||
LoadedResourceSet {
|
||||
Objects.requireNonNull(locale, "loaded resource locale must be non-null");
|
||||
Objects.requireNonNull(subject, "loaded subject container must be non-null");
|
||||
Objects.requireNonNull(text, "loaded text container must be non-null");
|
||||
Objects.requireNonNull(html, "loaded HTML container must be non-null");
|
||||
Objects.requireNonNull(slack, "loaded Slack container must be non-null");
|
||||
}
|
||||
|
||||
LoadedResourceSet withLocale(Locale selected) {
|
||||
return new LoadedResourceSet(selected, subject, text, html, slack);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class LoadBudget {
|
||||
|
||||
private final long maximumBytes;
|
||||
private final int maximumResources;
|
||||
private long loadedBytes;
|
||||
private int loadedResources;
|
||||
|
||||
private LoadBudget(long maximumBytes, int maximumResources) {
|
||||
this.maximumBytes = maximumBytes;
|
||||
this.maximumResources = maximumResources;
|
||||
}
|
||||
|
||||
private void consume(int bytes) {
|
||||
loadedBytes = Math.addExact(loadedBytes, bytes);
|
||||
loadedResources = Math.addExact(loadedResources, 1);
|
||||
if (loadedBytes > maximumBytes || loadedResources > maximumResources) {
|
||||
throw new TemplateRenderingException(
|
||||
"template catalog exceeds the aggregate resource budget");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.template;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationChannel;
|
||||
import dev.caskeleton.application.notification.NotificationTemplateRef;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Exact immutable classpath resource manifest for one reviewed template revision. */
|
||||
public record NotificationTemplateManifest(
|
||||
NotificationTemplateRef templateRef,
|
||||
NotificationChannel channel,
|
||||
String rendererRevision,
|
||||
String checksum,
|
||||
Set<Locale> supportedLocales,
|
||||
Locale fallbackLocale,
|
||||
Set<String> parameterNames,
|
||||
int maximumRenderedBytes,
|
||||
Map<Locale, ResourceSet> resources) {
|
||||
|
||||
public NotificationTemplateManifest {
|
||||
Objects.requireNonNull(templateRef, "template reference must be non-null");
|
||||
Objects.requireNonNull(channel, "template channel must be non-null");
|
||||
rendererRevision = requireSlug("renderer revision", rendererRevision);
|
||||
if (checksum == null || !checksum.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException("template checksum must be a lowercase SHA-256 digest");
|
||||
}
|
||||
Objects.requireNonNull(supportedLocales, "supported locales must be non-null");
|
||||
supportedLocales =
|
||||
supportedLocales.stream()
|
||||
.map(NotificationTemplateManifest::requireLocale)
|
||||
.sorted(Comparator.comparing(Locale::toLanguageTag))
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
if (supportedLocales.isEmpty() || supportedLocales.size() > 32) {
|
||||
throw new IllegalArgumentException("supported locales must contain 1..32 entries");
|
||||
}
|
||||
fallbackLocale = requireLocale(fallbackLocale);
|
||||
if (!supportedLocales.contains(fallbackLocale)) {
|
||||
throw new IllegalArgumentException("fallback locale must be supported");
|
||||
}
|
||||
Objects.requireNonNull(parameterNames, "template parameter names must be non-null");
|
||||
parameterNames =
|
||||
parameterNames.stream()
|
||||
.map(NotificationTemplateManifest::requireParameter)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
if (parameterNames.size() > 32) {
|
||||
throw new IllegalArgumentException("template parameter names exceed 32 entries");
|
||||
}
|
||||
Objects.requireNonNull(resources, "template resources must be non-null");
|
||||
TreeMap<String, Map.Entry<Locale, ResourceSet>> sorted = new TreeMap<>();
|
||||
resources.forEach(
|
||||
(locale, resourceSet) -> {
|
||||
Locale normalized = requireLocale(locale);
|
||||
if (sorted.put(
|
||||
normalized.toLanguageTag(),
|
||||
Map.entry(
|
||||
normalized,
|
||||
Objects.requireNonNull(
|
||||
resourceSet, "template resource set must be non-null")))
|
||||
!= null) {
|
||||
throw new IllegalArgumentException("duplicate normalized template locale");
|
||||
}
|
||||
});
|
||||
resources =
|
||||
sorted.values().stream()
|
||||
.collect(
|
||||
Collectors.toUnmodifiableMap(
|
||||
Map.Entry::getKey, Map.Entry::getValue, (left, right) -> left));
|
||||
if (!resources.keySet().equals(supportedLocales)) {
|
||||
throw new IllegalArgumentException(
|
||||
"template resource locales must exactly match supported locales");
|
||||
}
|
||||
if (new HashSet<>(resources.keySet()).size() != resources.size()) {
|
||||
throw new IllegalArgumentException("template resources contain duplicate locales");
|
||||
}
|
||||
resources.values().forEach(resource -> resource.validateFor(channel));
|
||||
if (maximumRenderedBytes < 1 || maximumRenderedBytes > 10_000_000) {
|
||||
throw new IllegalArgumentException("maximum rendered bytes must be in 1..10000000");
|
||||
}
|
||||
}
|
||||
|
||||
public Locale selectLocale(Locale requested) {
|
||||
Locale normalized = requireLocale(requested);
|
||||
return supportedLocales.contains(normalized) ? normalized : fallbackLocale;
|
||||
}
|
||||
|
||||
private static Locale requireLocale(Locale locale) {
|
||||
Objects.requireNonNull(locale, "template locale must be non-null");
|
||||
String tag = locale.toLanguageTag();
|
||||
if (locale.equals(Locale.ROOT) || tag.equals("und") || tag.isBlank() || tag.length() > 35) {
|
||||
throw new IllegalArgumentException("template locale must be explicit and bounded");
|
||||
}
|
||||
return Locale.forLanguageTag(tag);
|
||||
}
|
||||
|
||||
private static String requireParameter(String name) {
|
||||
if (name == null || !name.matches("[a-z][A-Za-z0-9]{0,63}")) {
|
||||
throw new IllegalArgumentException("template parameter must match [a-z][A-Za-z0-9]{0,63}");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
private static String requireSlug(String field, String value) {
|
||||
if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) {
|
||||
throw new IllegalArgumentException(field + " must match [a-z][a-z0-9.-]{0,62}");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Channel-shaped exact classpath paths; paths cannot escape the notification template root. */
|
||||
public record ResourceSet(
|
||||
Optional<String> subject,
|
||||
Optional<String> text,
|
||||
Optional<String> html,
|
||||
Optional<String> slack) {
|
||||
|
||||
public ResourceSet {
|
||||
subject = validateOptional(subject);
|
||||
text = validateOptional(text);
|
||||
html = validateOptional(html);
|
||||
slack = validateOptional(slack);
|
||||
}
|
||||
|
||||
public static ResourceSet email(String subject, String text, String html) {
|
||||
return new ResourceSet(
|
||||
Optional.of(subject), Optional.of(text), Optional.of(html), Optional.empty());
|
||||
}
|
||||
|
||||
public static ResourceSet slack(String slack) {
|
||||
return new ResourceSet(
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(slack));
|
||||
}
|
||||
|
||||
Map<String, String> rolePaths() {
|
||||
java.util.LinkedHashMap<String, String> paths = new java.util.LinkedHashMap<>();
|
||||
subject.ifPresent(path -> paths.put("subject", path));
|
||||
text.ifPresent(path -> paths.put("text", path));
|
||||
html.ifPresent(path -> paths.put("html", path));
|
||||
slack.ifPresent(path -> paths.put("slack", path));
|
||||
return Map.copyOf(paths);
|
||||
}
|
||||
|
||||
void validateFor(NotificationChannel channel) {
|
||||
boolean emailShape =
|
||||
subject.isPresent() && text.isPresent() && html.isPresent() && slack.isEmpty();
|
||||
boolean slackShape =
|
||||
subject.isEmpty() && text.isEmpty() && html.isEmpty() && slack.isPresent();
|
||||
if ((channel == NotificationChannel.EMAIL && !emailShape)
|
||||
|| (channel == NotificationChannel.SLACK && !slackShape)) {
|
||||
throw new IllegalArgumentException("template resources do not match channel shape");
|
||||
}
|
||||
}
|
||||
|
||||
private static Optional<String> validateOptional(Optional<String> path) {
|
||||
Objects.requireNonNull(path, "template resource path container must be non-null");
|
||||
return path.map(
|
||||
value -> {
|
||||
if (!value.matches("notification/templates/[a-z0-9._/-]{1,180}")
|
||||
|| value.contains("..")
|
||||
|| value.startsWith("/")
|
||||
|| value.contains("\\")) {
|
||||
throw new IllegalArgumentException(
|
||||
"template resource path must remain under notification/templates");
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.template;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationFrozenPlan;
|
||||
|
||||
/** Deterministic local renderer over an application-owned frozen plan. */
|
||||
@FunctionalInterface
|
||||
public interface NotificationTemplateRenderer<T extends RenderedNotification> {
|
||||
|
||||
T render(NotificationFrozenPlan plan);
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.template;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Closed typed renderer output; arbitrary provider JSON is deliberately absent. */
|
||||
public sealed interface RenderedNotification
|
||||
permits RenderedNotification.Email, RenderedNotification.Slack {
|
||||
|
||||
Locale locale();
|
||||
|
||||
int utf8Bytes();
|
||||
|
||||
record Email(Locale locale, String subject, String text, String html, int utf8Bytes)
|
||||
implements RenderedNotification {
|
||||
|
||||
public Email {
|
||||
Objects.requireNonNull(locale, "rendered email locale must be non-null");
|
||||
Objects.requireNonNull(subject, "rendered email subject must be non-null");
|
||||
Objects.requireNonNull(text, "rendered email text must be non-null");
|
||||
Objects.requireNonNull(html, "rendered email HTML must be non-null");
|
||||
int actual =
|
||||
subject.getBytes(StandardCharsets.UTF_8).length
|
||||
+ text.getBytes(StandardCharsets.UTF_8).length
|
||||
+ html.getBytes(StandardCharsets.UTF_8).length;
|
||||
if (utf8Bytes != actual || utf8Bytes < 1) {
|
||||
throw new IllegalArgumentException("rendered email byte count must be exact and positive");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Email[locale="
|
||||
+ locale.toLanguageTag()
|
||||
+ ", content=<redacted>, utf8Bytes="
|
||||
+ utf8Bytes
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
|
||||
record Slack(Locale locale, List<SlackBlock> blocks, int maximumDepth, int utf8Bytes)
|
||||
implements RenderedNotification {
|
||||
|
||||
public Slack {
|
||||
Objects.requireNonNull(locale, "rendered Slack locale must be non-null");
|
||||
Objects.requireNonNull(blocks, "rendered Slack blocks must be non-null");
|
||||
blocks =
|
||||
blocks.stream()
|
||||
.map(block -> Objects.requireNonNull(block, "Slack block must be non-null"))
|
||||
.toList();
|
||||
if (blocks.isEmpty() || blocks.size() > 50) {
|
||||
throw new IllegalArgumentException("rendered Slack block count must be in 1..50");
|
||||
}
|
||||
if (maximumDepth < 1 || maximumDepth > 8) {
|
||||
throw new IllegalArgumentException("rendered Slack depth must be in 1..8");
|
||||
}
|
||||
int actual =
|
||||
blocks.stream()
|
||||
.mapToInt(block -> block.text().getBytes(StandardCharsets.UTF_8).length)
|
||||
.sum();
|
||||
if (utf8Bytes != actual || utf8Bytes < 1) {
|
||||
throw new IllegalArgumentException("rendered Slack byte count must be exact and positive");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Slack[locale="
|
||||
+ locale.toLanguageTag()
|
||||
+ ", blocks=<redacted>, maximumDepth="
|
||||
+ maximumDepth
|
||||
+ ", utf8Bytes="
|
||||
+ utf8Bytes
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
|
||||
record SlackBlock(TextType type, String text, boolean verbatim) {
|
||||
|
||||
public SlackBlock {
|
||||
Objects.requireNonNull(type, "Slack block text type must be non-null");
|
||||
Objects.requireNonNull(text, "Slack block text must be non-null");
|
||||
if (text.isBlank() || text.length() > 3_000) {
|
||||
throw new IllegalArgumentException("Slack block text must contain 1..3000 characters");
|
||||
}
|
||||
if (type == TextType.MRKDWN && !verbatim) {
|
||||
throw new IllegalArgumentException(
|
||||
"Slack mrkdwn blocks must disable automatic link and mention expansion");
|
||||
}
|
||||
}
|
||||
|
||||
public static SlackBlock mrkdwn(String text) {
|
||||
return new SlackBlock(TextType.MRKDWN, text, true);
|
||||
}
|
||||
|
||||
public static SlackBlock plainText(String text) {
|
||||
return new SlackBlock(TextType.PLAIN_TEXT, text, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SlackBlock[type=" + type + ", text=<redacted>]";
|
||||
}
|
||||
}
|
||||
|
||||
enum TextType {
|
||||
MRKDWN,
|
||||
PLAIN_TEXT
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.template;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationChannel;
|
||||
import dev.caskeleton.application.notification.NotificationFrozenPlan;
|
||||
import dev.caskeleton.application.notification.NotificationTemplateValue;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Typed Block Kit renderer preserving plain_text versus mrkdwn context and bounded depth. */
|
||||
public final class SlackBlockKitRenderer
|
||||
implements NotificationTemplateRenderer<RenderedNotification.Slack> {
|
||||
|
||||
private static final Pattern TOKEN =
|
||||
Pattern.compile("\\{\\{([a-z][A-Za-z0-9]{0,63})\\|([a-z_]+)}}");
|
||||
|
||||
private final NotificationTemplateCatalog catalog;
|
||||
|
||||
public SlackBlockKitRenderer(NotificationTemplateCatalog catalog) {
|
||||
this.catalog = Objects.requireNonNull(catalog, "template catalog must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public RenderedNotification.Slack render(NotificationFrozenPlan plan) {
|
||||
Objects.requireNonNull(plan, "notification frozen plan must be non-null");
|
||||
NotificationTemplateManifest manifest = catalog.require(plan.policy().templateRef());
|
||||
LocalEmailRenderer.validatePlan(plan, manifest, NotificationChannel.SLACK);
|
||||
NotificationTemplateCatalog.LoadedResourceSet sources =
|
||||
catalog.sources(manifest, plan.selectedLocale());
|
||||
String template = sources.slack().orElseThrow();
|
||||
LocalEmailRenderer.validateTemplateUsage(List.of(template), manifest.parameterNames());
|
||||
LocalEmailRenderer.requireAggregateBoundedExpansion(
|
||||
List.of(template), plan.parameters().values(), manifest.maximumRenderedBytes(), 6);
|
||||
List<RenderedNotification.SlackBlock> blocks =
|
||||
renderBlocks(
|
||||
template,
|
||||
plan.parameters().values(),
|
||||
manifest.parameterNames(),
|
||||
manifest.maximumRenderedBytes());
|
||||
int bytes =
|
||||
blocks.stream()
|
||||
.mapToInt(block -> block.text().getBytes(StandardCharsets.UTF_8).length)
|
||||
.sum();
|
||||
if (bytes > manifest.maximumRenderedBytes()) {
|
||||
throw new TemplateRenderingException(
|
||||
"rendered Slack message exceeds the manifest byte bound");
|
||||
}
|
||||
return new RenderedNotification.Slack(sources.locale(), blocks, 3, bytes);
|
||||
}
|
||||
|
||||
private static List<RenderedNotification.SlackBlock> renderBlocks(
|
||||
String template,
|
||||
Map<String, NotificationTemplateValue> parameters,
|
||||
Set<String> manifestNames,
|
||||
int maximumBytes) {
|
||||
List<RenderedNotification.SlackBlock> blocks = new ArrayList<>();
|
||||
java.util.Iterator<String> lines = template.lines().iterator();
|
||||
while (lines.hasNext()) {
|
||||
if (blocks.size() == 50) {
|
||||
throw new TemplateRenderingException("rendered Slack block count is outside 1..50");
|
||||
}
|
||||
String line = lines.next();
|
||||
boolean mrkdwn = line.contains("|mrkdwn}}");
|
||||
if (mrkdwn && (line.contains("|plain}}") || line.contains("|mention}}"))) {
|
||||
throw new TemplateRenderingException(
|
||||
"Slack block cannot mix mrkdwn and plain_text contexts");
|
||||
}
|
||||
String rendered = renderLine(line, parameters, manifestNames, maximumBytes);
|
||||
blocks.add(
|
||||
mrkdwn
|
||||
? RenderedNotification.SlackBlock.mrkdwn(rendered)
|
||||
: RenderedNotification.SlackBlock.plainText(rendered));
|
||||
}
|
||||
if (blocks.isEmpty()) {
|
||||
throw new TemplateRenderingException("rendered Slack block count is outside 1..50");
|
||||
}
|
||||
return List.copyOf(blocks);
|
||||
}
|
||||
|
||||
private static String renderLine(
|
||||
String template,
|
||||
Map<String, NotificationTemplateValue> parameters,
|
||||
Set<String> manifestNames,
|
||||
int maximumBytes) {
|
||||
Matcher matcher = TOKEN.matcher(template);
|
||||
StringBuilder output = new StringBuilder(Math.min(template.length(), maximumBytes));
|
||||
while (matcher.find()) {
|
||||
String name = matcher.group(1);
|
||||
NotificationTemplateValue parameter = parameters.get(name);
|
||||
if (parameter == null || !manifestNames.contains(name)) {
|
||||
throw new TemplateRenderingException("Slack template references an unknown parameter");
|
||||
}
|
||||
String plain = escapeSlackPlain(LocalEmailRenderer.scalar(parameter));
|
||||
String replacement =
|
||||
switch (matcher.group(2)) {
|
||||
case "plain" -> plain;
|
||||
case "mrkdwn" ->
|
||||
plain
|
||||
.replace("\\", "\\\\")
|
||||
.replace("*", "\\*")
|
||||
.replace("_", "\\_")
|
||||
.replace("~", "\\~")
|
||||
.replace("`", "\\`");
|
||||
case "mention" -> plain.replace("@", "@\u200B");
|
||||
default -> throw new TemplateRenderingException("unknown Slack rendering context");
|
||||
};
|
||||
matcher.appendReplacement(output, Matcher.quoteReplacement(replacement));
|
||||
}
|
||||
matcher.appendTail(output);
|
||||
if (output.indexOf("{{") >= 0) {
|
||||
throw new TemplateRenderingException("Slack template has an unresolved parameter");
|
||||
}
|
||||
return output.toString();
|
||||
}
|
||||
|
||||
private static String escapeSlackPlain(String value) {
|
||||
return value.replace("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.template;
|
||||
|
||||
/** Redacted fail-closed template loading or rendering error. */
|
||||
public final class TemplateRenderingException extends RuntimeException {
|
||||
|
||||
public TemplateRenderingException(String safeMessage) {
|
||||
super(safeMessage);
|
||||
}
|
||||
|
||||
public TemplateRenderingException(String safeMessage, Throwable cause) {
|
||||
super(safeMessage, cause);
|
||||
}
|
||||
}
|
||||
+489
@@ -0,0 +1,489 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.catalog;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.notification.ConsentCheckMode;
|
||||
import dev.caskeleton.application.notification.EmailRecipientReference;
|
||||
import dev.caskeleton.application.notification.NotificationAdmissionClass;
|
||||
import dev.caskeleton.application.notification.NotificationChannel;
|
||||
import dev.caskeleton.application.notification.NotificationFrozenPlan;
|
||||
import dev.caskeleton.application.notification.NotificationIntentDraft;
|
||||
import dev.caskeleton.application.notification.NotificationIntentId;
|
||||
import dev.caskeleton.application.notification.NotificationKindId;
|
||||
import dev.caskeleton.application.notification.NotificationKindPolicy;
|
||||
import dev.caskeleton.application.notification.NotificationMode;
|
||||
import dev.caskeleton.application.notification.NotificationPlanningResult;
|
||||
import dev.caskeleton.application.notification.NotificationRouteId;
|
||||
import dev.caskeleton.application.notification.NotificationRouteStrategy;
|
||||
import dev.caskeleton.application.notification.NotificationTemplateParameters;
|
||||
import dev.caskeleton.application.notification.NotificationTemplateRef;
|
||||
import dev.caskeleton.application.notification.NotificationTemplateValue;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class NotificationBindingCompilerTest {
|
||||
|
||||
@Test
|
||||
void explicitCatalogCompilesToSortedImmutableGraphAndApplicationFrozenPlan() {
|
||||
NotificationBindingCompiler compiler = validCompiler();
|
||||
|
||||
NotificationBindingCompiler.CompiledGraph graph = compiler.compile();
|
||||
|
||||
assertThat(graph.bindings())
|
||||
.extracting(binding -> binding.route().routeId().value())
|
||||
.containsExactly("security-email", "security-slack");
|
||||
assertThat(graph.bindings()).isUnmodifiable();
|
||||
assertThat(graph.manifestDigest())
|
||||
.isEqualTo("de53ed4517827786b460009132c5aadad0825a12d3b8e442bb444203c7221ea7");
|
||||
assertThat(validCompiler().compile()).isEqualTo(graph);
|
||||
|
||||
NotificationPlanAdapter planner = new NotificationPlanAdapter(graph);
|
||||
NotificationPlanningResult result = planner.plan(emailDraft());
|
||||
assertThat(result).isInstanceOf(NotificationPlanningResult.Planned.class);
|
||||
NotificationFrozenPlan plan = ((NotificationPlanningResult.Planned) result).plan();
|
||||
assertThat(plan.policy().policyRevision()).isEqualTo(17);
|
||||
assertThat(plan.binding().routeRevision()).isEqualTo(3);
|
||||
assertThat(plan.binding().bindingDigest())
|
||||
.isEqualTo("f9d50c2d559236a397d3607e5fed100141c8673ab8262539ecbeb736b3bfb51d");
|
||||
assertThat(plan.binding().rendererRevision()).isEqualTo("email-renderer-r1");
|
||||
assertThat(plan.binding().targets())
|
||||
.extracting(NotificationFrozenPlan.FrozenTarget::providerCapabilityReference)
|
||||
.containsExactly("aws-ses-v2-durable-single-local-sns-v1");
|
||||
assertThat(plan.toString()).doesNotContain("secret-ref", "destination-ref");
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateUnknownAndChannelDriftFailClosed() {
|
||||
NotificationProviderDescriptor ses = sesProvider();
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationBindingCompiler(
|
||||
List.of(ses, ses), validTemplates(), validRoutes(), validProfiles())
|
||||
.compile())
|
||||
.isInstanceOf(NotificationCatalogException.class)
|
||||
.hasMessageContaining("duplicate provider");
|
||||
|
||||
NotificationRouteDescriptor unknownProfile =
|
||||
emailRoute(
|
||||
List.of(
|
||||
new NotificationRouteDescriptor.Target(
|
||||
"email-primary", "missing-profile", Optional.empty())));
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationBindingCompiler(
|
||||
validProviders(),
|
||||
validTemplates(),
|
||||
List.of(unknownProfile),
|
||||
validProfiles())
|
||||
.compile())
|
||||
.isInstanceOf(NotificationCatalogException.class)
|
||||
.hasMessageContaining("unknown runtime profile");
|
||||
|
||||
NotificationProviderRuntimeProfile channelDrift =
|
||||
new NotificationProviderRuntimeProfile(
|
||||
"email-runtime-r1",
|
||||
"slack-web-api",
|
||||
"slack-binding-r1",
|
||||
"slack-web-api-durable-single-local-v1",
|
||||
"credential-r1",
|
||||
"secret-ref-r1",
|
||||
"destination-ref-r1");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationBindingCompiler(
|
||||
validProviders(),
|
||||
validTemplates(),
|
||||
List.of(emailRoute()),
|
||||
List.of(channelDrift))
|
||||
.compile())
|
||||
.isInstanceOf(NotificationCatalogException.class)
|
||||
.hasMessageContaining("channel");
|
||||
|
||||
NotificationProviderDescriptor masquerading =
|
||||
new NotificationProviderDescriptor(
|
||||
"unknown-slack-family",
|
||||
NotificationChannel.SLACK,
|
||||
false,
|
||||
List.of(
|
||||
NotificationProviderCapabilityCard.initial(
|
||||
"slack-web-api-durable-single-local-v1")));
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationBindingCompiler(
|
||||
List.of(masquerading),
|
||||
validTemplates(),
|
||||
List.of(slackRoute("slack-runtime-r1", false, false, 1)),
|
||||
List.of(
|
||||
new NotificationProviderRuntimeProfile(
|
||||
"slack-runtime-r1",
|
||||
"unknown-slack-family",
|
||||
"slack-binding-r1",
|
||||
"slack-web-api-durable-single-local-v1",
|
||||
"credential-r1",
|
||||
"secret-ref-r1",
|
||||
"destination-ref-r1")))
|
||||
.compile())
|
||||
.isInstanceOf(NotificationCatalogException.class)
|
||||
.hasMessageContaining("provider family");
|
||||
}
|
||||
|
||||
@Test
|
||||
void retainedRouteRevisionsRemainCompiledWhileOneExplicitRevisionIsActive() {
|
||||
NotificationRouteDescriptor retainedEmail = withRevision(emailRoute(), 2);
|
||||
NotificationBindingCompiler.CompiledGraph graph =
|
||||
new NotificationBindingCompiler(
|
||||
validProviders(),
|
||||
validTemplates(),
|
||||
List.of(
|
||||
retainedEmail, emailRoute(), slackRoute("slack-runtime-r1", false, false, 1)),
|
||||
validProfiles(),
|
||||
Map.of(
|
||||
new NotificationRouteId("security-email"),
|
||||
3,
|
||||
new NotificationRouteId("security-slack"),
|
||||
3))
|
||||
.compile();
|
||||
|
||||
assertThat(graph.bindings())
|
||||
.extracting(
|
||||
binding -> binding.route().routeId().value() + "#" + binding.route().routeRevision())
|
||||
.containsExactly("security-email#2", "security-email#3", "security-slack#3");
|
||||
assertThat(
|
||||
graph.activeBinding(new NotificationRouteId("security-email")).route().routeRevision())
|
||||
.isEqualTo(3);
|
||||
assertThat(graph.bindings())
|
||||
.anyMatch(
|
||||
binding ->
|
||||
binding.route().routeId().value().equals("security-email")
|
||||
&& binding.route().routeRevision() == 2);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationBindingCompiler(
|
||||
validProviders(),
|
||||
validTemplates(),
|
||||
List.of(retainedEmail, emailRoute()),
|
||||
validProfiles()))
|
||||
.isInstanceOf(NotificationCatalogException.class)
|
||||
.hasMessageContaining("explicit active revision");
|
||||
}
|
||||
|
||||
@Test
|
||||
void compiledArtifactsCannotBeForgedThroughPublicConstructors() {
|
||||
assertThat(CompiledNotificationBinding.class.getDeclaredConstructors())
|
||||
.allMatch(constructor -> !java.lang.reflect.Modifier.isPublic(constructor.getModifiers()));
|
||||
assertThat(CompiledNotificationBinding.CompiledTarget.class.getDeclaredConstructors())
|
||||
.allMatch(constructor -> !java.lang.reflect.Modifier.isPublic(constructor.getModifiers()));
|
||||
assertThat(NotificationBindingCompiler.CompiledGraph.class.getDeclaredConstructors())
|
||||
.allMatch(constructor -> !java.lang.reflect.Modifier.isPublic(constructor.getModifiers()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyReceiptUnsafeFallbackBoundsAndCyclesAreRejected() {
|
||||
NotificationProviderDescriptor legacy =
|
||||
new NotificationProviderDescriptor(
|
||||
"slack-webhook", NotificationChannel.SLACK, true, List.of());
|
||||
NotificationProviderRuntimeProfile legacyProfile =
|
||||
new NotificationProviderRuntimeProfile(
|
||||
"legacy-runtime-r0",
|
||||
"slack-webhook",
|
||||
"legacy-binding-r0",
|
||||
"legacy-r0",
|
||||
"credential-r0",
|
||||
"legacy-secret-ref",
|
||||
"legacy-destination-ref");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationBindingCompiler(
|
||||
List.of(legacy),
|
||||
validTemplates(),
|
||||
List.of(slackRoute("legacy-runtime-r0", false, false, 1)),
|
||||
List.of(legacyProfile))
|
||||
.compile())
|
||||
.isInstanceOf(NotificationCatalogException.class)
|
||||
.hasMessageContaining("legacy");
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationBindingCompiler(
|
||||
validProviders(),
|
||||
validTemplates(),
|
||||
List.of(slackRoute("slack-runtime-r1", true, false, 1)),
|
||||
validProfiles())
|
||||
.compile())
|
||||
.isInstanceOf(NotificationCatalogException.class)
|
||||
.hasMessageContaining("receipt");
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationBindingCompiler(
|
||||
validProviders(),
|
||||
validTemplates(),
|
||||
List.of(slackRoute("slack-runtime-r1", false, true, 1)),
|
||||
validProfiles())
|
||||
.compile())
|
||||
.isInstanceOf(NotificationCatalogException.class)
|
||||
.hasMessageContaining("indeterminate");
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationBindingCompiler(
|
||||
validProviders(),
|
||||
validTemplates(),
|
||||
List.of(slackRoute("slack-runtime-r1", false, false, 2)),
|
||||
validProfiles())
|
||||
.compile())
|
||||
.isInstanceOf(NotificationCatalogException.class)
|
||||
.hasMessageContaining("attempt");
|
||||
|
||||
NotificationRouteDescriptor unsupportedFanOut =
|
||||
new NotificationRouteDescriptor(
|
||||
new NotificationRouteId("security-slack"),
|
||||
3,
|
||||
NotificationChannel.SLACK,
|
||||
NotificationMode.DURABLE_ASYNC,
|
||||
NotificationAdmissionClass.TRANSACTIONAL,
|
||||
NotificationRouteStrategy.FAN_OUT_ALL,
|
||||
new NotificationTemplateRef("security-slack", 1),
|
||||
false,
|
||||
false,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
Duration.ofSeconds(5),
|
||||
List.of(
|
||||
new NotificationRouteDescriptor.Target(
|
||||
"target-a", "slack-runtime-r1", Optional.empty()),
|
||||
new NotificationRouteDescriptor.Target(
|
||||
"target-b", "slack-runtime-r1", Optional.empty())));
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationBindingCompiler(
|
||||
validProviders(),
|
||||
validTemplates(),
|
||||
List.of(unsupportedFanOut),
|
||||
validProfiles())
|
||||
.compile())
|
||||
.isInstanceOf(NotificationCatalogException.class)
|
||||
.hasMessageContaining("strategy");
|
||||
|
||||
NotificationRouteDescriptor cyclic =
|
||||
new NotificationRouteDescriptor(
|
||||
new NotificationRouteId("security-slack"),
|
||||
3,
|
||||
NotificationChannel.SLACK,
|
||||
NotificationMode.DURABLE_ASYNC,
|
||||
NotificationAdmissionClass.TRANSACTIONAL,
|
||||
NotificationRouteStrategy.ORDERED_FALLBACK,
|
||||
new NotificationTemplateRef("security-slack", 1),
|
||||
false,
|
||||
false,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
Duration.ofSeconds(5),
|
||||
List.of(
|
||||
new NotificationRouteDescriptor.Target(
|
||||
"target-a", "slack-runtime-r1", Optional.of("target-b")),
|
||||
new NotificationRouteDescriptor.Target(
|
||||
"target-b", "slack-runtime-r1", Optional.of("target-a"))));
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationBindingCompiler(
|
||||
validProviders(), validTemplates(), List.of(cyclic), validProfiles())
|
||||
.compile())
|
||||
.isInstanceOf(NotificationCatalogException.class)
|
||||
.hasMessageContaining("strategy");
|
||||
}
|
||||
|
||||
static NotificationBindingCompiler validCompiler() {
|
||||
return new NotificationBindingCompiler(
|
||||
validProviders(), validTemplates(), validRoutes(), validProfiles());
|
||||
}
|
||||
|
||||
static List<NotificationProviderDescriptor> validProviders() {
|
||||
return List.of(
|
||||
sesProvider(),
|
||||
new NotificationProviderDescriptor(
|
||||
"slack-web-api",
|
||||
NotificationChannel.SLACK,
|
||||
false,
|
||||
List.of(
|
||||
NotificationProviderCapabilityCard.initial("slack-web-api-inline-single-local-v1"),
|
||||
NotificationProviderCapabilityCard.initial(
|
||||
"slack-web-api-durable-single-local-v1"))));
|
||||
}
|
||||
|
||||
static NotificationProviderDescriptor sesProvider() {
|
||||
return new NotificationProviderDescriptor(
|
||||
"aws-ses-v2",
|
||||
NotificationChannel.EMAIL,
|
||||
false,
|
||||
List.of(
|
||||
NotificationProviderCapabilityCard.initial("aws-ses-v2-durable-single-local-sns-v1")));
|
||||
}
|
||||
|
||||
static List<NotificationTemplateDescriptor> validTemplates() {
|
||||
return List.of(
|
||||
new NotificationTemplateDescriptor(
|
||||
new NotificationTemplateRef("security-email", 1),
|
||||
NotificationChannel.EMAIL,
|
||||
"email-renderer-r1",
|
||||
"1".repeat(64),
|
||||
Set.of(Locale.ENGLISH, Locale.KOREAN),
|
||||
Locale.ENGLISH,
|
||||
Set.of("displayName"),
|
||||
64_000),
|
||||
new NotificationTemplateDescriptor(
|
||||
new NotificationTemplateRef("security-slack", 1),
|
||||
NotificationChannel.SLACK,
|
||||
"slack-renderer-r1",
|
||||
"2".repeat(64),
|
||||
Set.of(Locale.ENGLISH),
|
||||
Locale.ENGLISH,
|
||||
Set.of("displayName"),
|
||||
32_000));
|
||||
}
|
||||
|
||||
static List<NotificationRouteDescriptor> validRoutes() {
|
||||
return List.of(emailRoute(), slackRoute("slack-runtime-r1", false, false, 1));
|
||||
}
|
||||
|
||||
static NotificationRouteDescriptor emailRoute() {
|
||||
return emailRoute(
|
||||
List.of(
|
||||
new NotificationRouteDescriptor.Target(
|
||||
"email-primary", "email-runtime-r1", Optional.empty())));
|
||||
}
|
||||
|
||||
static NotificationRouteDescriptor emailRoute(List<NotificationRouteDescriptor.Target> targets) {
|
||||
return new NotificationRouteDescriptor(
|
||||
new NotificationRouteId("security-email"),
|
||||
3,
|
||||
NotificationChannel.EMAIL,
|
||||
NotificationMode.DURABLE_ASYNC,
|
||||
NotificationAdmissionClass.SECURITY_CRITICAL,
|
||||
NotificationRouteStrategy.SINGLE,
|
||||
new NotificationTemplateRef("security-email", 1),
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
Duration.ofSeconds(5),
|
||||
targets);
|
||||
}
|
||||
|
||||
private static NotificationRouteDescriptor withRevision(
|
||||
NotificationRouteDescriptor route, int revision) {
|
||||
return new NotificationRouteDescriptor(
|
||||
route.routeId(),
|
||||
revision,
|
||||
route.channel(),
|
||||
route.mode(),
|
||||
route.admissionClass(),
|
||||
route.routeStrategy(),
|
||||
route.templateRef(),
|
||||
route.receiptRequired(),
|
||||
route.fallbackAfterIndeterminate(),
|
||||
route.maximumTargets(),
|
||||
route.maximumPhysicalAttempts(),
|
||||
route.maximumFallbackActivations(),
|
||||
route.maximumReconcileCalls(),
|
||||
route.maximumTotalProviderCalls(),
|
||||
route.perAttemptDeadline(),
|
||||
route.targets());
|
||||
}
|
||||
|
||||
static NotificationRouteDescriptor slackRoute(
|
||||
String profileId,
|
||||
boolean receiptRequired,
|
||||
boolean fallbackAfterIndeterminate,
|
||||
int maximumAttempts) {
|
||||
return new NotificationRouteDescriptor(
|
||||
new NotificationRouteId("security-slack"),
|
||||
3,
|
||||
NotificationChannel.SLACK,
|
||||
NotificationMode.DURABLE_ASYNC,
|
||||
NotificationAdmissionClass.TRANSACTIONAL,
|
||||
NotificationRouteStrategy.SINGLE,
|
||||
new NotificationTemplateRef("security-slack", 1),
|
||||
receiptRequired,
|
||||
fallbackAfterIndeterminate,
|
||||
1,
|
||||
maximumAttempts,
|
||||
0,
|
||||
0,
|
||||
maximumAttempts,
|
||||
Duration.ofSeconds(5),
|
||||
List.of(
|
||||
new NotificationRouteDescriptor.Target("slack-primary", profileId, Optional.empty())));
|
||||
}
|
||||
|
||||
static List<NotificationProviderRuntimeProfile> validProfiles() {
|
||||
return List.of(
|
||||
new NotificationProviderRuntimeProfile(
|
||||
"email-runtime-r1",
|
||||
"aws-ses-v2",
|
||||
"ses-binding-r1",
|
||||
"aws-ses-v2-durable-single-local-sns-v1",
|
||||
"credential-r1",
|
||||
"secret-ref-r1",
|
||||
"destination-ref-r1"),
|
||||
new NotificationProviderRuntimeProfile(
|
||||
"slack-runtime-r1",
|
||||
"slack-web-api",
|
||||
"slack-binding-r1",
|
||||
"slack-web-api-durable-single-local-v1",
|
||||
"credential-r1",
|
||||
"secret-ref-r1",
|
||||
"destination-ref-r1"));
|
||||
}
|
||||
|
||||
private static NotificationIntentDraft emailDraft() {
|
||||
NotificationKindPolicy policy =
|
||||
new NotificationKindPolicy(
|
||||
new NotificationKindId("security-alert"),
|
||||
NotificationChannel.EMAIL,
|
||||
new NotificationRouteId("security-email"),
|
||||
new NotificationTemplateRef("security-email", 1),
|
||||
NotificationMode.DURABLE_ASYNC,
|
||||
NotificationAdmissionClass.SECURITY_CRITICAL,
|
||||
NotificationRouteStrategy.SINGLE,
|
||||
ConsentCheckMode.RECHECK_BEFORE_EACH_DELIVERY,
|
||||
17,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
Duration.ofMinutes(10));
|
||||
return new NotificationIntentDraft(
|
||||
new NotificationIntentId("intent-42"),
|
||||
policy,
|
||||
Locale.KOREAN,
|
||||
new EmailRecipientReference("recipient-ref-42"),
|
||||
new NotificationTemplateParameters(
|
||||
Map.of("displayName", new NotificationTemplateValue.SafeText("Ada"))),
|
||||
"idempotency-scope-42",
|
||||
"operation-42",
|
||||
Optional.empty(),
|
||||
"correlation-42",
|
||||
Optional.empty(),
|
||||
Instant.parse("2026-07-29T00:00:00Z"),
|
||||
Instant.parse("2026-07-29T00:10:00Z"));
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.catalog;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationCanonicalWriterRouteSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class NotificationCanonicalRouteCatalogTest {
|
||||
|
||||
@Test
|
||||
void retainedKeyOnlyCatalogMapsOnlyWithExactReviewedGenerationConfig() {
|
||||
NotificationCanonicalRouteCatalog catalog =
|
||||
NotificationCanonicalRouteCatalog.fromCompiledGraph(
|
||||
NotificationBindingCompilerTest.validCompiler().compile());
|
||||
List<NotificationCanonicalRouteCatalog.RouteKey> keys = catalog.routes();
|
||||
Map<NotificationCanonicalRouteCatalog.RouteKey, Long> generations =
|
||||
Map.of(keys.get(0), 7L, keys.get(1), 3L);
|
||||
|
||||
NotificationCanonicalWriterRouteSet application = catalog.toApplication(generations);
|
||||
|
||||
assertThat(application.routes())
|
||||
.extracting(route -> route.routeId().value())
|
||||
.containsExactly("security-email", "security-slack");
|
||||
assertThat(catalog.digest())
|
||||
.isEqualTo("c5bad4ab5dffa98fb73e14629e9b62dd6d62ec4c562c526b67680937484559e1");
|
||||
assertThat(
|
||||
NotificationCanonicalRouteCatalog.fromCompiledGraph(
|
||||
NotificationBindingCompilerTest.validCompiler().compile()))
|
||||
.isEqualTo(catalog);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generationConfigAndRouteKeysMustMatchExactly() {
|
||||
NotificationCanonicalRouteCatalog catalog =
|
||||
NotificationCanonicalRouteCatalog.fromCompiledGraph(
|
||||
NotificationBindingCompilerTest.validCompiler().compile());
|
||||
NotificationCanonicalRouteCatalog.RouteKey first = catalog.routes().getFirst();
|
||||
|
||||
assertThatThrownBy(() -> catalog.toApplication(Map.of(first, 7L)))
|
||||
.isInstanceOf(NotificationCatalogException.class)
|
||||
.hasMessageContaining("exactly");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
NotificationCanonicalRouteCatalog.fromRoutes(
|
||||
List.of(
|
||||
NotificationBindingCompilerTest.emailRoute(),
|
||||
NotificationBindingCompilerTest.emailRoute())))
|
||||
.isInstanceOf(NotificationCatalogException.class)
|
||||
.hasMessageContaining("duplicate");
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.catalog;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationWriterRouteSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class NotificationCutoverRouteCatalogTest {
|
||||
|
||||
@Test
|
||||
void preDecoratorCannotChangeCanonicalKeysAndDigestCoversCompleteProofRegistry() {
|
||||
NotificationCanonicalRouteCatalog canonical =
|
||||
NotificationCanonicalRouteCatalog.fromCompiledGraph(
|
||||
NotificationBindingCompilerTest.validCompiler().compile());
|
||||
NotificationCutoverRouteCatalog catalog =
|
||||
new NotificationCutoverRouteCatalog(canonical, validCutoverRoutes(canonical));
|
||||
Map<NotificationCanonicalRouteCatalog.RouteKey, Long> generations =
|
||||
Map.of(canonical.routes().get(0), 7L, canonical.routes().get(1), 3L);
|
||||
|
||||
NotificationWriterRouteSet application = catalog.toApplication(generations);
|
||||
|
||||
assertThat(application.routeProfiles()).hasSize(2);
|
||||
assertThat(application.routeProfiles().getFirst().transportProfiles())
|
||||
.extracting(NotificationWriterRouteSet.TransportProfile::profileId)
|
||||
.containsExactly("legacy-http-v1", "legacy-http-v2");
|
||||
assertThat(catalog.digest())
|
||||
.isEqualTo("62b2da4489adbec34cc124da093b1e95e8d0c4999ca3f3e824b2859592474faa");
|
||||
|
||||
NotificationCutoverRouteCatalog changed =
|
||||
new NotificationCutoverRouteCatalog(
|
||||
canonical,
|
||||
List.of(
|
||||
new NotificationCutoverRouteCatalog.CutoverRoute(
|
||||
canonical.routes().get(0),
|
||||
Optional.of("legacy-email"),
|
||||
List.of(
|
||||
new NotificationCutoverRouteCatalog.LegacyTransportProfile(
|
||||
"legacy-http-v1",
|
||||
NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED,
|
||||
"evidence-r2",
|
||||
true,
|
||||
false))),
|
||||
validCutoverRoutes(canonical).get(1)));
|
||||
assertThat(changed.digest()).isNotEqualTo(catalog.digest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingKeysAliasesProfilesAndUnreviewedHardBoundsFailClosed() {
|
||||
NotificationCanonicalRouteCatalog canonical =
|
||||
NotificationCanonicalRouteCatalog.fromCompiledGraph(
|
||||
NotificationBindingCompilerTest.validCompiler().compile());
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationCutoverRouteCatalog(
|
||||
canonical, List.of(validCutoverRoutes(canonical).getFirst())))
|
||||
.isInstanceOf(NotificationCatalogException.class)
|
||||
.hasMessageContaining("exactly");
|
||||
|
||||
List<NotificationCutoverRouteCatalog.CutoverRoute> duplicateAlias =
|
||||
List.of(
|
||||
validCutoverRoutes(canonical).get(0),
|
||||
new NotificationCutoverRouteCatalog.CutoverRoute(
|
||||
canonical.routes().get(1),
|
||||
Optional.of("legacy-email"),
|
||||
List.of(
|
||||
new NotificationCutoverRouteCatalog.LegacyTransportProfile(
|
||||
"legacy-slack-v1",
|
||||
NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED,
|
||||
"evidence-r1",
|
||||
true,
|
||||
false))));
|
||||
assertThatThrownBy(() -> new NotificationCutoverRouteCatalog(canonical, duplicateAlias))
|
||||
.isInstanceOf(NotificationCatalogException.class)
|
||||
.hasMessageContaining("alias");
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationCutoverRouteCatalog.LegacyTransportProfile(
|
||||
"legacy-hard-v1",
|
||||
NotificationWriterRouteSet.ProofClass.HARD_BOUND_PROVEN,
|
||||
"hard-bound-r1",
|
||||
true,
|
||||
false))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("reviewed");
|
||||
}
|
||||
|
||||
static List<NotificationCutoverRouteCatalog.CutoverRoute> validCutoverRoutes(
|
||||
NotificationCanonicalRouteCatalog canonical) {
|
||||
return List.of(
|
||||
new NotificationCutoverRouteCatalog.CutoverRoute(
|
||||
canonical.routes().get(0),
|
||||
Optional.of("legacy-email"),
|
||||
List.of(
|
||||
new NotificationCutoverRouteCatalog.LegacyTransportProfile(
|
||||
"legacy-http-v1",
|
||||
NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED,
|
||||
"evidence-r1",
|
||||
false,
|
||||
false),
|
||||
new NotificationCutoverRouteCatalog.LegacyTransportProfile(
|
||||
"legacy-http-v2",
|
||||
NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED,
|
||||
"evidence-r2",
|
||||
true,
|
||||
false))),
|
||||
new NotificationCutoverRouteCatalog.CutoverRoute(
|
||||
canonical.routes().get(1),
|
||||
Optional.of("legacy-slack"),
|
||||
List.of(
|
||||
new NotificationCutoverRouteCatalog.LegacyTransportProfile(
|
||||
"legacy-slack-v1",
|
||||
NotificationWriterRouteSet.ProofClass.HARD_BOUND_PROVEN,
|
||||
"hard-bound-r2",
|
||||
true,
|
||||
true))));
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.template.RenderedNotification;
|
||||
import dev.caskeleton.application.notification.NotificationMode;
|
||||
import dev.caskeleton.application.notification.NotificationRequestResult;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class InlineNotificationAttemptAdapterTest {
|
||||
|
||||
@Test
|
||||
void inlinePlanInvokesOneClientPerFrozenTargetAndDurablePlanIsRejected() {
|
||||
AtomicInteger sends = new AtomicInteger();
|
||||
NotificationProviderAttemptClient client =
|
||||
new NotificationProviderAttemptClient() {
|
||||
@Override
|
||||
public PreparedNotificationAttempt prepare(
|
||||
RenderedNotification rendered,
|
||||
dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding
|
||||
.CompiledTarget
|
||||
target,
|
||||
NotificationAttemptContext context) {
|
||||
return new PreparedNotificationAttempt(rendered, target, context, "c".repeat(64));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientAttemptResult sendOneAuthorizedAttempt(
|
||||
PreparedNotificationAttempt prepared,
|
||||
String executionToken,
|
||||
NotificationSecretMaterialHandle secret,
|
||||
Instant absoluteDeadline) {
|
||||
sends.incrementAndGet();
|
||||
return new ClientAttemptResult.Accepted(
|
||||
new ProviderMessageReference("provider-message-42"));
|
||||
}
|
||||
};
|
||||
InlineNotificationAttemptAdapter adapter =
|
||||
new InlineNotificationAttemptAdapter(
|
||||
ProviderTestFixtures.attemptAdapter(client, NotificationMode.BEST_EFFORT_INLINE));
|
||||
|
||||
NotificationRequestResult.InlineCompleted result =
|
||||
adapter.attempt(ProviderTestFixtures.plan(NotificationMode.BEST_EFFORT_INLINE));
|
||||
|
||||
assertThat(sends).hasValue(1);
|
||||
assertThat(result.outcomes()).hasSize(1);
|
||||
assertThatThrownBy(
|
||||
() -> adapter.attempt(ProviderTestFixtures.plan(NotificationMode.DURABLE_ASYNC)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("inline");
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile;
|
||||
import dev.caskeleton.application.notification.NotificationAdmissionReadinessPort;
|
||||
import dev.caskeleton.application.notification.NotificationFaultScope;
|
||||
import dev.caskeleton.application.notification.NotificationReasonCode;
|
||||
import dev.caskeleton.application.notification.NotificationRouteId;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class NotificationAdmissionReadinessAdapterTest {
|
||||
|
||||
@Test
|
||||
void internalNonSecretProbeMustBeFreshAndExactBeforeDelegatedResume() {
|
||||
AtomicBoolean resumed = new AtomicBoolean();
|
||||
NotificationAdmissionReadinessPort state =
|
||||
new NotificationAdmissionReadinessPort() {
|
||||
@Override
|
||||
public ParkResult park(ParkRequest request) {
|
||||
return ParkResult.PARKED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResumeResult resume(
|
||||
ResumeRequest request, ReadinessProbe probe, Instant resumedAt) {
|
||||
resumed.set(true);
|
||||
return new ResumeResult(
|
||||
ResumeStatus.RESUMED, request.expectedGeneration() + 1, 1, 0, 0, 0, 0, 0);
|
||||
}
|
||||
};
|
||||
NotificationProviderRuntimeProfile profile = profile("credential-r1");
|
||||
NotificationAdmissionReadinessAdapter adapter =
|
||||
new NotificationAdmissionReadinessAdapter(
|
||||
state,
|
||||
request -> profile,
|
||||
(selected, deadline) ->
|
||||
new NotificationProviderReadinessSnapshot(
|
||||
selected.profileId(),
|
||||
selected.bindingRevision(),
|
||||
selected.capabilityCardId(),
|
||||
selected.credentialGeneration(),
|
||||
true,
|
||||
new NotificationReasonCode("READINESS_CONFIRMED"),
|
||||
ProviderTestFixtures.NOW,
|
||||
ProviderTestFixtures.NOW.plusSeconds(10)),
|
||||
Clock.fixed(ProviderTestFixtures.NOW, ZoneOffset.UTC));
|
||||
NotificationAdmissionReadinessPort.ResumeRequest request = request();
|
||||
|
||||
NotificationAdmissionReadinessPort.ReadinessProbe probe = adapter.probe(request);
|
||||
var result = adapter.resume(request, probe, ProviderTestFixtures.NOW);
|
||||
|
||||
assertThat(probe.ready()).isTrue();
|
||||
assertThat(result.status()).isEqualTo(NotificationAdmissionReadinessPort.ResumeStatus.RESUMED);
|
||||
assertThat(resumed).isTrue();
|
||||
assertThat(adapter.toString()).doesNotContain("secret-ref-r1", "destination-ref-r1");
|
||||
assertThatThrownBy(
|
||||
() -> adapter.resume(request, probe, ProviderTestFixtures.NOW.plusSeconds(2)))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("fresh");
|
||||
}
|
||||
|
||||
@Test
|
||||
void probeFailuresTokenSubstitutionAndCredentialRotationFailClosed() {
|
||||
AtomicReference<NotificationProviderRuntimeProfile> current =
|
||||
new AtomicReference<>(profile("credential-r1"));
|
||||
NotificationAdmissionReadinessAdapter adapter =
|
||||
new NotificationAdmissionReadinessAdapter(
|
||||
request -> NotificationAdmissionReadinessPort.ParkResult.PARKED,
|
||||
request -> current.get(),
|
||||
(selected, deadline) ->
|
||||
new NotificationProviderReadinessSnapshot(
|
||||
selected.profileId(),
|
||||
selected.bindingRevision(),
|
||||
selected.capabilityCardId(),
|
||||
selected.credentialGeneration(),
|
||||
true,
|
||||
new NotificationReasonCode("READINESS_CONFIRMED"),
|
||||
ProviderTestFixtures.NOW,
|
||||
ProviderTestFixtures.NOW.plusSeconds(10)),
|
||||
Clock.fixed(ProviderTestFixtures.NOW, ZoneOffset.UTC));
|
||||
NotificationAdmissionReadinessPort.ResumeRequest original = request();
|
||||
NotificationAdmissionReadinessPort.ReadinessProbe ready = adapter.probe(original);
|
||||
NotificationAdmissionReadinessPort.ResumeRequest substituted =
|
||||
new NotificationAdmissionReadinessPort.ResumeRequest(
|
||||
original.operationToken(),
|
||||
original.routeId(),
|
||||
original.policyRevision(),
|
||||
original.faultScope(),
|
||||
original.scopeReference(),
|
||||
original.expectedGeneration(),
|
||||
original.maximumParkedLegs(),
|
||||
"different-operator",
|
||||
original.reasonCode());
|
||||
|
||||
NotificationAdmissionReadinessPort.ReadinessProbe conflict = adapter.probe(substituted);
|
||||
|
||||
assertThat(conflict.ready()).isFalse();
|
||||
assertThat(conflict.reasonCode().value()).isEqualTo("READINESS_TOKEN_CONFLICT");
|
||||
current.set(profile("credential-r2"));
|
||||
assertThatThrownBy(
|
||||
() -> adapter.resume(original, ready, ProviderTestFixtures.NOW.plusSeconds(1)))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("generation");
|
||||
|
||||
NotificationAdmissionReadinessAdapter failing =
|
||||
new NotificationAdmissionReadinessAdapter(
|
||||
request -> NotificationAdmissionReadinessPort.ParkResult.PARKED,
|
||||
request -> {
|
||||
throw new IllegalStateException("secret reference must not escape");
|
||||
},
|
||||
(selected, deadline) -> {
|
||||
throw new AssertionError("probe must not run");
|
||||
},
|
||||
Clock.fixed(ProviderTestFixtures.NOW, ZoneOffset.UTC));
|
||||
NotificationAdmissionReadinessPort.ReadinessProbe failed = failing.probe(request());
|
||||
assertThat(failed.ready()).isFalse();
|
||||
assertThat(failed.reasonCode().value()).isEqualTo("READINESS_PROBE_FAILED");
|
||||
assertThat(failed.toString()).doesNotContain("secret reference");
|
||||
}
|
||||
|
||||
@Test
|
||||
void readinessObservationAfterProbeIoIsValidatedAgainstPostCallTime() {
|
||||
AtomicReference<Instant> currentTime = new AtomicReference<>(ProviderTestFixtures.NOW);
|
||||
Clock advancingClock =
|
||||
new Clock() {
|
||||
@Override
|
||||
public ZoneId getZone() {
|
||||
return ZoneOffset.UTC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(ZoneId zone) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return currentTime.get();
|
||||
}
|
||||
};
|
||||
NotificationAdmissionReadinessAdapter adapter =
|
||||
new NotificationAdmissionReadinessAdapter(
|
||||
request -> NotificationAdmissionReadinessPort.ParkResult.PARKED,
|
||||
request -> profile("credential-r1"),
|
||||
(selected, deadline) -> {
|
||||
currentTime.set(ProviderTestFixtures.NOW.plusSeconds(1));
|
||||
return new NotificationProviderReadinessSnapshot(
|
||||
selected.profileId(),
|
||||
selected.bindingRevision(),
|
||||
selected.capabilityCardId(),
|
||||
selected.credentialGeneration(),
|
||||
true,
|
||||
new NotificationReasonCode("READINESS_CONFIRMED"),
|
||||
currentTime.get(),
|
||||
currentTime.get().plusSeconds(10));
|
||||
},
|
||||
advancingClock);
|
||||
|
||||
NotificationAdmissionReadinessPort.ReadinessProbe probe = adapter.probe(request());
|
||||
|
||||
assertThat(probe.ready()).isTrue();
|
||||
}
|
||||
|
||||
private static NotificationProviderRuntimeProfile profile(String credentialGeneration) {
|
||||
return new NotificationProviderRuntimeProfile(
|
||||
"slack-runtime-r1",
|
||||
"slack-web-api",
|
||||
"slack-binding-r1",
|
||||
"slack-web-api-durable-single-local-v1",
|
||||
credentialGeneration,
|
||||
"secret-ref-r1",
|
||||
"destination-ref-r1");
|
||||
}
|
||||
|
||||
private static NotificationAdmissionReadinessPort.ResumeRequest request() {
|
||||
return new NotificationAdmissionReadinessPort.ResumeRequest(
|
||||
"resume-operation-42",
|
||||
new NotificationRouteId("security-slack"),
|
||||
3,
|
||||
NotificationFaultScope.PROVIDER_BINDING,
|
||||
"provider-binding-scope-42",
|
||||
7,
|
||||
10,
|
||||
"operator-42",
|
||||
new NotificationReasonCode("OPERATOR_RESUME"));
|
||||
}
|
||||
}
|
||||
+402
@@ -0,0 +1,402 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationBindingCompiler;
|
||||
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationPlanAdapter;
|
||||
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderCapabilityCard;
|
||||
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderDescriptor;
|
||||
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile;
|
||||
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationRouteDescriptor;
|
||||
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationTemplateDescriptor;
|
||||
import dev.caskeleton.adapter.outbound.notification.template.RenderedNotification;
|
||||
import dev.caskeleton.application.notification.ConsentCheckMode;
|
||||
import dev.caskeleton.application.notification.NotificationAdmissionClass;
|
||||
import dev.caskeleton.application.notification.NotificationChannel;
|
||||
import dev.caskeleton.application.notification.NotificationDeliveryId;
|
||||
import dev.caskeleton.application.notification.NotificationDeliveryStorePort;
|
||||
import dev.caskeleton.application.notification.NotificationFrozenPlan;
|
||||
import dev.caskeleton.application.notification.NotificationIntentDraft;
|
||||
import dev.caskeleton.application.notification.NotificationIntentId;
|
||||
import dev.caskeleton.application.notification.NotificationKindId;
|
||||
import dev.caskeleton.application.notification.NotificationKindPolicy;
|
||||
import dev.caskeleton.application.notification.NotificationMode;
|
||||
import dev.caskeleton.application.notification.NotificationPlanningResult;
|
||||
import dev.caskeleton.application.notification.NotificationProviderAttemptPort;
|
||||
import dev.caskeleton.application.notification.NotificationReasonCode;
|
||||
import dev.caskeleton.application.notification.NotificationRouteId;
|
||||
import dev.caskeleton.application.notification.NotificationRouteStrategy;
|
||||
import dev.caskeleton.application.notification.NotificationTemplateParameters;
|
||||
import dev.caskeleton.application.notification.NotificationTemplateRef;
|
||||
import dev.caskeleton.application.notification.NotificationTemplateValue;
|
||||
import dev.caskeleton.application.notification.RetryDisposition;
|
||||
import dev.caskeleton.application.notification.SlackAudienceReference;
|
||||
import dev.caskeleton.application.notification.SubmissionCertainty;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class NotificationProviderAttemptContractTest {
|
||||
|
||||
@Test
|
||||
void oneAuthorizationPreparesWithoutWireIoAndInvokesClientExactlyOnceWithinAbsoluteDeadline() {
|
||||
AtomicInteger prepareCalls = new AtomicInteger();
|
||||
AtomicInteger wireCalls = new AtomicInteger();
|
||||
AtomicReference<Instant> observedDeadline = new AtomicReference<>();
|
||||
NotificationProviderAttemptClient client =
|
||||
new NotificationProviderAttemptClient() {
|
||||
@Override
|
||||
public PreparedNotificationAttempt prepare(
|
||||
RenderedNotification rendered,
|
||||
dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding
|
||||
.CompiledTarget
|
||||
target,
|
||||
NotificationAttemptContext context) {
|
||||
prepareCalls.incrementAndGet();
|
||||
assertThat(wireCalls).hasValue(0);
|
||||
return new PreparedNotificationAttempt(rendered, target, context, "c".repeat(64));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientAttemptResult sendOneAuthorizedAttempt(
|
||||
PreparedNotificationAttempt prepared,
|
||||
String executionToken,
|
||||
NotificationSecretMaterialHandle secret,
|
||||
Instant absoluteDeadline) {
|
||||
wireCalls.incrementAndGet();
|
||||
observedDeadline.set(absoluteDeadline);
|
||||
return new ClientAttemptResult.Accepted(
|
||||
new ProviderMessageReference("provider-message-42"));
|
||||
}
|
||||
};
|
||||
Instant now = ProviderTestFixtures.NOW;
|
||||
NotificationDeliveryStorePort.AuthorizedAttempt authorized =
|
||||
ProviderTestFixtures.authorized(NotificationMode.DURABLE_ASYNC, now.plusSeconds(3));
|
||||
NotificationProviderAttemptPort adapter =
|
||||
ProviderTestFixtures.attemptAdapter(client, NotificationMode.DURABLE_ASYNC);
|
||||
|
||||
var outcome = adapter.attempt(authorized);
|
||||
|
||||
assertThat(prepareCalls).hasValue(1);
|
||||
assertThat(wireCalls).hasValue(1);
|
||||
assertThat(observedDeadline.get()).isEqualTo(now.plusSeconds(3));
|
||||
assertThat(outcome.submissionCertainty()).isEqualTo(SubmissionCertainty.PROVIDER_ACCEPTED);
|
||||
assertThat(outcome.providerMessageReference()).contains("provider-message-42");
|
||||
assertThat(outcome.attemptCorrelationReference()).isNotEqualTo(authorized.executionToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preWireValidationAndPossibleWriteFailuresHaveDifferentCertaintyAndSdkErrorsNeverEscape() {
|
||||
NotificationProviderAttemptClient invalidBeforeWire =
|
||||
new NotificationProviderAttemptClient() {
|
||||
@Override
|
||||
public PreparedNotificationAttempt prepare(
|
||||
RenderedNotification rendered,
|
||||
dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding
|
||||
.CompiledTarget
|
||||
target,
|
||||
NotificationAttemptContext context) {
|
||||
throw new NotificationProviderAttemptClient.PreWireDeliveryRejectedException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientAttemptResult sendOneAuthorizedAttempt(
|
||||
PreparedNotificationAttempt prepared,
|
||||
String executionToken,
|
||||
NotificationSecretMaterialHandle secret,
|
||||
Instant absoluteDeadline) {
|
||||
throw new AssertionError("wire call must not run");
|
||||
}
|
||||
};
|
||||
NotificationProviderAttemptClient responseLost =
|
||||
new NotificationProviderAttemptClient() {
|
||||
@Override
|
||||
public PreparedNotificationAttempt prepare(
|
||||
RenderedNotification rendered,
|
||||
dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding
|
||||
.CompiledTarget
|
||||
target,
|
||||
NotificationAttemptContext context) {
|
||||
return new PreparedNotificationAttempt(rendered, target, context, "c".repeat(64));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientAttemptResult sendOneAuthorizedAttempt(
|
||||
PreparedNotificationAttempt prepared,
|
||||
String executionToken,
|
||||
NotificationSecretMaterialHandle secret,
|
||||
Instant absoluteDeadline) {
|
||||
throw new IllegalStateException("SDK body and token must not escape");
|
||||
}
|
||||
};
|
||||
|
||||
var preWire =
|
||||
ProviderTestFixtures.attemptAdapter(invalidBeforeWire, NotificationMode.DURABLE_ASYNC)
|
||||
.attempt(
|
||||
ProviderTestFixtures.authorized(
|
||||
NotificationMode.DURABLE_ASYNC, ProviderTestFixtures.NOW.plusSeconds(3)));
|
||||
var possibleWrite =
|
||||
ProviderTestFixtures.attemptAdapter(responseLost, NotificationMode.DURABLE_ASYNC)
|
||||
.attempt(
|
||||
ProviderTestFixtures.authorized(
|
||||
NotificationMode.DURABLE_ASYNC, ProviderTestFixtures.NOW.plusSeconds(3)));
|
||||
|
||||
assertThat(preWire.submissionCertainty()).isEqualTo(SubmissionCertainty.DEFINITELY_NOT_APPLIED);
|
||||
assertThat(preWire.retryDisposition()).isEqualTo(RetryDisposition.TERMINAL);
|
||||
assertThat(possibleWrite.submissionCertainty()).isEqualTo(SubmissionCertainty.INDETERMINATE);
|
||||
assertThat(possibleWrite.toString()).doesNotContain("SDK body", "token must not escape");
|
||||
}
|
||||
|
||||
@Test
|
||||
void preparedAttemptMustRemainBoundToTheAuthorizedRenderedTargetAndContext() {
|
||||
AtomicInteger wireCalls = new AtomicInteger();
|
||||
NotificationProviderAttemptClient misbindingClient =
|
||||
new NotificationProviderAttemptClient() {
|
||||
@Override
|
||||
public PreparedNotificationAttempt prepare(
|
||||
RenderedNotification rendered,
|
||||
dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding
|
||||
.CompiledTarget
|
||||
target,
|
||||
NotificationAttemptContext context) {
|
||||
NotificationAttemptContext differentContext =
|
||||
new NotificationAttemptContext(
|
||||
context.deliveryId(),
|
||||
context.attemptId(),
|
||||
context.targetOrdinal(),
|
||||
context.correlationId(),
|
||||
Optional.of(
|
||||
new NotificationAttemptContext.ProviderClientOperationKey(
|
||||
"different-operation-key")),
|
||||
context.absoluteDeadline());
|
||||
return new PreparedNotificationAttempt(
|
||||
rendered, target, differentContext, "c".repeat(64));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientAttemptResult sendOneAuthorizedAttempt(
|
||||
PreparedNotificationAttempt prepared,
|
||||
String executionToken,
|
||||
NotificationSecretMaterialHandle secret,
|
||||
Instant absoluteDeadline) {
|
||||
wireCalls.incrementAndGet();
|
||||
throw new AssertionError("misbound prepared attempt must not reach the wire");
|
||||
}
|
||||
};
|
||||
|
||||
var outcome =
|
||||
ProviderTestFixtures.attemptAdapter(misbindingClient, NotificationMode.DURABLE_ASYNC)
|
||||
.attempt(
|
||||
ProviderTestFixtures.authorized(
|
||||
NotificationMode.DURABLE_ASYNC, ProviderTestFixtures.NOW.plusSeconds(3)));
|
||||
|
||||
assertThat(wireCalls).hasValue(0);
|
||||
assertThat(outcome.submissionCertainty()).isEqualTo(SubmissionCertainty.DEFINITELY_NOT_APPLIED);
|
||||
assertThat(outcome.retryDisposition()).isEqualTo(RetryDisposition.PARK_BINDING);
|
||||
assertThat(outcome.reasonCode().value()).isEqualTo("PROVIDER_PREPARED_ATTEMPT_MISMATCH");
|
||||
}
|
||||
}
|
||||
|
||||
final class ProviderTestFixtures {
|
||||
|
||||
static final Instant NOW = Instant.parse("2026-07-29T00:00:00Z");
|
||||
|
||||
private ProviderTestFixtures() {}
|
||||
|
||||
static NotificationProviderAttemptAdapter attemptAdapter(
|
||||
NotificationProviderAttemptClient client, NotificationMode mode) {
|
||||
NotificationBindingCompiler.CompiledGraph graph = graph(mode);
|
||||
return new NotificationProviderAttemptAdapter(
|
||||
graph,
|
||||
Map.of(
|
||||
"slack-renderer-r1",
|
||||
plan ->
|
||||
new RenderedNotification.Slack(
|
||||
plan.selectedLocale(),
|
||||
List.of(RenderedNotification.SlackBlock.mrkdwn("safe message")),
|
||||
3,
|
||||
"safe message".getBytes(java.nio.charset.StandardCharsets.UTF_8).length)),
|
||||
Map.of(cardId(mode), client),
|
||||
(profile, deadline) ->
|
||||
NotificationProviderRateAdmission.Decision.admitted(
|
||||
new NotificationReasonCode("RATE_ADMISSION_GRANTED")),
|
||||
profile ->
|
||||
NotificationSecretMaterialHandle.fromBytes(
|
||||
profile.credentialGeneration(),
|
||||
"provider-test-secret-material-32"
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8)),
|
||||
Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
static NotificationDeliveryStorePort.AuthorizedAttempt authorized(
|
||||
NotificationMode mode, Instant deadline) {
|
||||
NotificationFrozenPlan plan = plan(mode);
|
||||
return new NotificationDeliveryStorePort.AuthorizedAttempt(
|
||||
new NotificationDeliveryId("delivery-42"),
|
||||
new dev.caskeleton.application.notification.NotificationAttemptId("attempt-42"),
|
||||
plan,
|
||||
0,
|
||||
"claim-token-42",
|
||||
"execution-token-42",
|
||||
3,
|
||||
7,
|
||||
"provider-binding-scope-42",
|
||||
deadline);
|
||||
}
|
||||
|
||||
static NotificationFrozenPlan plan(NotificationMode mode) {
|
||||
NotificationKindPolicy policy =
|
||||
new NotificationKindPolicy(
|
||||
new NotificationKindId("security-slack"),
|
||||
NotificationChannel.SLACK,
|
||||
new NotificationRouteId("security-slack"),
|
||||
new NotificationTemplateRef("security-slack", 1),
|
||||
mode,
|
||||
NotificationAdmissionClass.TRANSACTIONAL,
|
||||
NotificationRouteStrategy.SINGLE,
|
||||
ConsentCheckMode.SNAPSHOT_AT_APPEND,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
Duration.ofMinutes(10));
|
||||
NotificationIntentDraft draft =
|
||||
new NotificationIntentDraft(
|
||||
new NotificationIntentId("intent-42"),
|
||||
policy,
|
||||
Locale.ENGLISH,
|
||||
new SlackAudienceReference("workspace-ref-42", "audience-ref-42"),
|
||||
new NotificationTemplateParameters(
|
||||
Map.of("displayName", new NotificationTemplateValue.SafeText("Ada"))),
|
||||
"scope-42",
|
||||
"operation-42",
|
||||
Optional.empty(),
|
||||
"correlation-42",
|
||||
Optional.empty(),
|
||||
NOW,
|
||||
NOW.plusSeconds(60));
|
||||
NotificationPlanningResult result = new NotificationPlanAdapter(graph(mode)).plan(draft);
|
||||
return ((NotificationPlanningResult.Planned) result).plan();
|
||||
}
|
||||
|
||||
static NotificationBindingCompiler.CompiledGraph graph(NotificationMode mode) {
|
||||
String cardId = cardId(mode);
|
||||
NotificationProviderDescriptor provider =
|
||||
new NotificationProviderDescriptor(
|
||||
"slack-web-api",
|
||||
NotificationChannel.SLACK,
|
||||
false,
|
||||
List.of(NotificationProviderCapabilityCard.initial(cardId)));
|
||||
NotificationTemplateDescriptor template =
|
||||
new NotificationTemplateDescriptor(
|
||||
new NotificationTemplateRef("security-slack", 1),
|
||||
NotificationChannel.SLACK,
|
||||
"slack-renderer-r1",
|
||||
"b".repeat(64),
|
||||
Set.of(Locale.ENGLISH),
|
||||
Locale.ENGLISH,
|
||||
Set.of("displayName"),
|
||||
32_000);
|
||||
NotificationRouteDescriptor route =
|
||||
new NotificationRouteDescriptor(
|
||||
new NotificationRouteId("security-slack"),
|
||||
3,
|
||||
NotificationChannel.SLACK,
|
||||
mode,
|
||||
NotificationAdmissionClass.TRANSACTIONAL,
|
||||
NotificationRouteStrategy.SINGLE,
|
||||
template.templateRef(),
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
Duration.ofSeconds(5),
|
||||
List.of(
|
||||
new NotificationRouteDescriptor.Target(
|
||||
"slack-primary", "slack-runtime-r1", Optional.empty())));
|
||||
NotificationProviderRuntimeProfile profile =
|
||||
new NotificationProviderRuntimeProfile(
|
||||
"slack-runtime-r1",
|
||||
"slack-web-api",
|
||||
"slack-binding-r1",
|
||||
cardId,
|
||||
"credential-r1",
|
||||
"secret-ref-r1",
|
||||
"destination-ref-r1");
|
||||
return new NotificationBindingCompiler(
|
||||
List.of(provider), List.of(template), List.of(route), List.of(profile))
|
||||
.compile();
|
||||
}
|
||||
|
||||
static NotificationBindingCompiler.CompiledGraph emailGraph() {
|
||||
String cardId = "aws-ses-v2-durable-single-local-sns-v1";
|
||||
NotificationProviderDescriptor provider =
|
||||
new NotificationProviderDescriptor(
|
||||
"aws-ses-v2",
|
||||
NotificationChannel.EMAIL,
|
||||
false,
|
||||
List.of(NotificationProviderCapabilityCard.initial(cardId)));
|
||||
NotificationTemplateDescriptor template =
|
||||
new NotificationTemplateDescriptor(
|
||||
new NotificationTemplateRef("security-email", 1),
|
||||
NotificationChannel.EMAIL,
|
||||
"email-renderer-r1",
|
||||
"e".repeat(64),
|
||||
Set.of(Locale.ENGLISH),
|
||||
Locale.ENGLISH,
|
||||
Set.of("displayName"),
|
||||
64_000);
|
||||
NotificationRouteDescriptor route =
|
||||
new NotificationRouteDescriptor(
|
||||
new NotificationRouteId("security-email"),
|
||||
3,
|
||||
NotificationChannel.EMAIL,
|
||||
NotificationMode.DURABLE_ASYNC,
|
||||
NotificationAdmissionClass.SECURITY_CRITICAL,
|
||||
NotificationRouteStrategy.SINGLE,
|
||||
template.templateRef(),
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
Duration.ofSeconds(5),
|
||||
List.of(
|
||||
new NotificationRouteDescriptor.Target(
|
||||
"email-primary", "email-runtime-r1", Optional.empty())));
|
||||
NotificationProviderRuntimeProfile profile =
|
||||
new NotificationProviderRuntimeProfile(
|
||||
"email-runtime-r1",
|
||||
"aws-ses-v2",
|
||||
"ses-binding-r1",
|
||||
cardId,
|
||||
"credential-r1",
|
||||
"secret-ref-r1",
|
||||
"destination-ref-r1");
|
||||
return new NotificationBindingCompiler(
|
||||
List.of(provider), List.of(template), List.of(route), List.of(profile))
|
||||
.compile();
|
||||
}
|
||||
|
||||
static String cardId(NotificationMode mode) {
|
||||
return mode == NotificationMode.BEST_EFFORT_INLINE
|
||||
? "slack-web-api-inline-single-local-v1"
|
||||
: "slack-web-api-durable-single-local-v1";
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.application.notification.NotificationDeliveryId;
|
||||
import dev.caskeleton.application.notification.NotificationDeliveryStorePort;
|
||||
import dev.caskeleton.application.notification.NotificationReasonCode;
|
||||
import dev.caskeleton.application.notification.SubmissionCertainty;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class NotificationReconciliationAdapterTest {
|
||||
|
||||
@Test
|
||||
void expiredLookupRemainsIndeterminateAndDoesNotCallProvider() {
|
||||
NotificationDeliveryStorePort.ReconciliationClaim expired =
|
||||
claim(
|
||||
"correlation-42",
|
||||
NotificationDeliveryStorePort.ReconciliationLookupKind.PRE_SEND_CORRELATION,
|
||||
ProviderTestFixtures.NOW);
|
||||
NotificationReconciliationAdapter adapter =
|
||||
new NotificationReconciliationAdapter(
|
||||
ignored -> {
|
||||
throw new AssertionError("expired reconciliation must not resolve a client");
|
||||
},
|
||||
ignored -> {
|
||||
throw new AssertionError("expired reconciliation must not acquire a secret");
|
||||
},
|
||||
Clock.fixed(ProviderTestFixtures.NOW, ZoneOffset.UTC));
|
||||
|
||||
var result = adapter.reconcile(expired);
|
||||
|
||||
assertThat(result.submissionCertainty()).isEqualTo(SubmissionCertainty.INDETERMINATE);
|
||||
assertThat(result.reasonCode().value()).isEqualTo("RECONCILIATION_DEADLINE_EXPIRED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedPreSendCorrelationLookupIsBoundedAndClientFailuresBecomeIndeterminate() {
|
||||
AtomicReference<ReconciliationLookupMode> mode = new AtomicReference<>();
|
||||
AtomicReference<NotificationProviderAttemptClient.ClientReconciliationResult> clientResult =
|
||||
new AtomicReference<>(
|
||||
new NotificationProviderAttemptClient.ClientReconciliationResult(
|
||||
SubmissionCertainty.PROVIDER_ACCEPTED,
|
||||
new NotificationReasonCode("PROVIDER_ACCEPTED")));
|
||||
NotificationProviderAttemptClient client =
|
||||
new NotificationProviderAttemptClient() {
|
||||
@Override
|
||||
public PreparedNotificationAttempt prepare(
|
||||
dev.caskeleton.adapter.outbound.notification.template.RenderedNotification rendered,
|
||||
dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding
|
||||
.CompiledTarget
|
||||
target,
|
||||
NotificationAttemptContext context) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientAttemptResult sendOneAuthorizedAttempt(
|
||||
PreparedNotificationAttempt prepared,
|
||||
String executionToken,
|
||||
NotificationSecretMaterialHandle secret,
|
||||
Instant absoluteDeadline) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientReconciliationResult reconcile(
|
||||
ReconciliationReference reference,
|
||||
ReconciliationLookupMode lookupMode,
|
||||
NotificationSecretMaterialHandle secret,
|
||||
Instant absoluteDeadline) {
|
||||
mode.set(lookupMode);
|
||||
return clientResult.get();
|
||||
}
|
||||
};
|
||||
NotificationDeliveryStorePort.ReconciliationClaim claim =
|
||||
claim(
|
||||
"attempt-correlation-42",
|
||||
NotificationDeliveryStorePort.ReconciliationLookupKind.PRE_SEND_CORRELATION,
|
||||
ProviderTestFixtures.NOW.plusSeconds(3));
|
||||
NotificationReconciliationAdapter adapter =
|
||||
new NotificationReconciliationAdapter(
|
||||
ignored -> binding(client),
|
||||
profile ->
|
||||
NotificationSecretMaterialHandle.fromBytes(
|
||||
profile.credentialGeneration(), new byte[] {1}),
|
||||
Clock.fixed(ProviderTestFixtures.NOW, ZoneOffset.UTC));
|
||||
|
||||
var result = adapter.reconcile(claim);
|
||||
|
||||
assertThat(mode).hasValue(ReconciliationLookupMode.PRE_SEND_CORRELATION);
|
||||
assertThat(result.submissionCertainty()).isEqualTo(SubmissionCertainty.PROVIDER_ACCEPTED);
|
||||
|
||||
clientResult.set(
|
||||
new NotificationProviderAttemptClient.ClientReconciliationResult(
|
||||
SubmissionCertainty.DEFINITELY_NOT_APPLIED,
|
||||
new NotificationReasonCode("PROVIDER_EVENT_NOT_FOUND")));
|
||||
var unprovenNonApplication = adapter.reconcile(claim);
|
||||
assertThat(unprovenNonApplication.submissionCertainty())
|
||||
.isEqualTo(SubmissionCertainty.INDETERMINATE);
|
||||
assertThat(unprovenNonApplication.reasonCode().value())
|
||||
.isEqualTo("RECONCILIATION_NONAPPLICATION_UNPROVEN");
|
||||
|
||||
mode.set(null);
|
||||
var unsupported =
|
||||
adapter.reconcile(
|
||||
claim(
|
||||
"provider-message-42",
|
||||
NotificationDeliveryStorePort.ReconciliationLookupKind.MESSAGE_REFERENCE,
|
||||
ProviderTestFixtures.NOW.plusSeconds(3)));
|
||||
assertThat(mode.get()).isNull();
|
||||
assertThat(unsupported.submissionCertainty()).isEqualTo(SubmissionCertainty.INDETERMINATE);
|
||||
assertThat(unsupported.reasonCode().value()).isEqualTo("RECONCILIATION_UNSUPPORTED");
|
||||
|
||||
NotificationReconciliationAdapter failing =
|
||||
new NotificationReconciliationAdapter(
|
||||
ignored -> binding(new ThrowingReconciliationClient()),
|
||||
profile ->
|
||||
NotificationSecretMaterialHandle.fromBytes(
|
||||
profile.credentialGeneration(), new byte[] {1}),
|
||||
Clock.fixed(ProviderTestFixtures.NOW, ZoneOffset.UTC));
|
||||
assertThat(failing.reconcile(claim).submissionCertainty())
|
||||
.isEqualTo(SubmissionCertainty.INDETERMINATE);
|
||||
}
|
||||
|
||||
private static NotificationDeliveryStorePort.ReconciliationClaim claim(
|
||||
String lookupReference,
|
||||
NotificationDeliveryStorePort.ReconciliationLookupKind lookupKind,
|
||||
Instant deadline) {
|
||||
var compiled = compiledBinding();
|
||||
var target = compiled.targets().getFirst();
|
||||
return new NotificationDeliveryStorePort.ReconciliationClaim(
|
||||
new NotificationDeliveryId("delivery-42"),
|
||||
"reconcile-token-42",
|
||||
3,
|
||||
compiled.route().routeId(),
|
||||
compiled.route().routeRevision(),
|
||||
compiled.bindingDigest(),
|
||||
0,
|
||||
target.target().targetId(),
|
||||
target.capabilityCard().cardId(),
|
||||
target.runtimeProfile().bindingRevision(),
|
||||
target.runtimeProfile().credentialGeneration(),
|
||||
lookupReference,
|
||||
lookupKind,
|
||||
deadline);
|
||||
}
|
||||
|
||||
private static NotificationReconciliationAdapter.ReconciliationBinding binding(
|
||||
NotificationProviderAttemptClient client) {
|
||||
var compiled = compiledBinding();
|
||||
return new NotificationReconciliationAdapter.ReconciliationBinding(compiled, 0, client);
|
||||
}
|
||||
|
||||
private static dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding
|
||||
compiledBinding() {
|
||||
return ProviderTestFixtures.emailGraph().bindings().getFirst();
|
||||
}
|
||||
|
||||
private static final class ThrowingReconciliationClient
|
||||
implements NotificationProviderAttemptClient {
|
||||
|
||||
@Override
|
||||
public PreparedNotificationAttempt prepare(
|
||||
dev.caskeleton.adapter.outbound.notification.template.RenderedNotification rendered,
|
||||
dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding
|
||||
.CompiledTarget
|
||||
target,
|
||||
NotificationAttemptContext context) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientAttemptResult sendOneAuthorizedAttempt(
|
||||
PreparedNotificationAttempt prepared,
|
||||
String executionToken,
|
||||
NotificationSecretMaterialHandle secret,
|
||||
Instant absoluteDeadline) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientReconciliationResult reconcile(
|
||||
ReconciliationReference reference,
|
||||
ReconciliationLookupMode lookupMode,
|
||||
NotificationSecretMaterialHandle secret,
|
||||
Instant absoluteDeadline) {
|
||||
throw new IllegalStateException("raw SDK reconciliation body");
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.provider;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class NotificationSecretMaterialHandleTest {
|
||||
|
||||
@Test
|
||||
void mutableCopyIsVersionedRedactedWipedAndRejectedAfterClose() {
|
||||
byte[] caller = "sensitive-token".getBytes(StandardCharsets.UTF_8);
|
||||
AtomicReference<byte[]> borrowed = new AtomicReference<>();
|
||||
NotificationSecretMaterialHandle handle =
|
||||
NotificationSecretMaterialHandle.fromBytes("secret-r7", caller);
|
||||
Arrays.fill(caller, (byte) 0);
|
||||
|
||||
String observed =
|
||||
handle.readBytes(
|
||||
bytes -> {
|
||||
borrowed.set(bytes);
|
||||
return new String(bytes, StandardCharsets.UTF_8);
|
||||
});
|
||||
|
||||
assertThat(observed).isEqualTo("sensitive-token");
|
||||
assertThat(handle.revision()).isEqualTo("secret-r7");
|
||||
assertThat(handle.toString()).doesNotContain("sensitive-token");
|
||||
handle.close();
|
||||
assertThat(borrowed.get()).containsOnly((byte) 0);
|
||||
assertThatThrownBy(() -> handle.readBytes(bytes -> bytes.length))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("closed")
|
||||
.hasMessageNotContaining("sensitive-token");
|
||||
}
|
||||
}
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.template;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.notification.ConsentCheckMode;
|
||||
import dev.caskeleton.application.notification.EmailRecipientReference;
|
||||
import dev.caskeleton.application.notification.NotificationAdmissionClass;
|
||||
import dev.caskeleton.application.notification.NotificationChannel;
|
||||
import dev.caskeleton.application.notification.NotificationFrozenPlan;
|
||||
import dev.caskeleton.application.notification.NotificationIntentDraft;
|
||||
import dev.caskeleton.application.notification.NotificationIntentId;
|
||||
import dev.caskeleton.application.notification.NotificationKindId;
|
||||
import dev.caskeleton.application.notification.NotificationKindPolicy;
|
||||
import dev.caskeleton.application.notification.NotificationMode;
|
||||
import dev.caskeleton.application.notification.NotificationRouteId;
|
||||
import dev.caskeleton.application.notification.NotificationRouteStrategy;
|
||||
import dev.caskeleton.application.notification.NotificationTemplateParameters;
|
||||
import dev.caskeleton.application.notification.NotificationTemplateRef;
|
||||
import dev.caskeleton.application.notification.NotificationTemplateValue;
|
||||
import dev.caskeleton.application.notification.SlackAudienceReference;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class NotificationTemplateRendererTest {
|
||||
|
||||
private static final NotificationTemplateRef EMAIL_REF =
|
||||
new NotificationTemplateRef("contract-email", 1);
|
||||
private static final NotificationTemplateRef SLACK_REF =
|
||||
new NotificationTemplateRef("contract-slack", 1);
|
||||
|
||||
@Test
|
||||
void exactManifestChecksumAndRendererRevisionProduceBoundedEmail() {
|
||||
NotificationTemplateCatalog catalog = validCatalog(2_000);
|
||||
NotificationFrozenPlan plan =
|
||||
emailPlan(
|
||||
Locale.KOREAN,
|
||||
Map.of(
|
||||
"displayName", new NotificationTemplateValue.SafeText("<Ada & \"Lovelace\">"),
|
||||
"query", new NotificationTemplateValue.SafeText("alpha beta&gamma")),
|
||||
catalog.require(EMAIL_REF));
|
||||
|
||||
RenderedNotification.Email rendered = new LocalEmailRenderer(catalog).render(plan);
|
||||
|
||||
assertThat(rendered.locale()).isEqualTo(Locale.KOREAN);
|
||||
assertThat(rendered.subject()).isEqualTo("Contract notice for <Ada & \"Lovelace\">");
|
||||
assertThat(rendered.html())
|
||||
.contains("<Ada & "Lovelace">")
|
||||
.contains("alpha%20beta%26gamma")
|
||||
.doesNotContain("<Ada");
|
||||
assertThat(rendered.utf8Bytes()).isLessThanOrEqualTo(2_000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void localeFallbackIsExactAndIndependentOfJvmDefault() {
|
||||
NotificationTemplateCatalog catalog = validCatalog(2_000);
|
||||
assertThat(catalog.require(EMAIL_REF).selectLocale(Locale.FRENCH)).isEqualTo(Locale.ENGLISH);
|
||||
Locale original = Locale.getDefault();
|
||||
try {
|
||||
Locale.setDefault(Locale.JAPANESE);
|
||||
RenderedNotification.Email rendered =
|
||||
new LocalEmailRenderer(catalog)
|
||||
.render(
|
||||
emailPlan(
|
||||
Locale.ENGLISH,
|
||||
Map.of(
|
||||
"displayName", new NotificationTemplateValue.SafeText("Ada"),
|
||||
"query", new NotificationTemplateValue.SafeText("contract")),
|
||||
catalog.require(EMAIL_REF)));
|
||||
|
||||
assertThat(rendered.locale()).isEqualTo(Locale.ENGLISH);
|
||||
} finally {
|
||||
Locale.setDefault(original);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void checksumRevisionParametersHeadersAndOutputBoundsFailClosedAndRedacted() {
|
||||
NotificationTemplateCatalog catalog = validCatalog(80);
|
||||
NotificationTemplateManifest manifest = catalog.require(EMAIL_REF);
|
||||
NotificationTemplateCatalog roomyCatalog = validCatalog(2_000);
|
||||
NotificationTemplateManifest roomyManifest = roomyCatalog.require(EMAIL_REF);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new LocalEmailRenderer(catalog)
|
||||
.render(
|
||||
emailPlan(
|
||||
Locale.ENGLISH,
|
||||
Map.of(
|
||||
"displayName", new NotificationTemplateValue.SafeText("Ada"),
|
||||
"query", new NotificationTemplateValue.SafeText("secret-value")),
|
||||
"0".repeat(64),
|
||||
manifest.rendererRevision())))
|
||||
.isInstanceOf(TemplateRenderingException.class)
|
||||
.hasMessageContaining("checksum")
|
||||
.hasMessageNotContaining("secret-value");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new LocalEmailRenderer(catalog)
|
||||
.render(
|
||||
emailPlan(
|
||||
Locale.ENGLISH,
|
||||
Map.of(
|
||||
"displayName", new NotificationTemplateValue.SafeText("Ada"),
|
||||
"query", new NotificationTemplateValue.SafeText("secret-value")),
|
||||
manifest.checksum(),
|
||||
"different-renderer")))
|
||||
.isInstanceOf(TemplateRenderingException.class)
|
||||
.hasMessageContaining("renderer")
|
||||
.hasMessageNotContaining("secret-value");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new LocalEmailRenderer(catalog)
|
||||
.render(
|
||||
emailPlan(
|
||||
Locale.ENGLISH,
|
||||
Map.of("displayName", new NotificationTemplateValue.SafeText("Ada")),
|
||||
manifest)))
|
||||
.isInstanceOf(TemplateRenderingException.class)
|
||||
.hasMessageContaining("parameter");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new LocalEmailRenderer(roomyCatalog)
|
||||
.render(
|
||||
emailPlan(
|
||||
Locale.ENGLISH,
|
||||
Map.of(
|
||||
"displayName",
|
||||
new NotificationTemplateValue.SafeText("Ada\r\nBcc: victim"),
|
||||
"query", new NotificationTemplateValue.SafeText("secret-value")),
|
||||
roomyManifest)))
|
||||
.isInstanceOf(TemplateRenderingException.class)
|
||||
.hasMessageContaining("header")
|
||||
.hasMessageNotContaining("victim");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new LocalEmailRenderer(catalog)
|
||||
.render(
|
||||
emailPlan(
|
||||
Locale.ENGLISH,
|
||||
Map.of(
|
||||
"displayName",
|
||||
new NotificationTemplateValue.SafeText("A".repeat(100)),
|
||||
"query", new NotificationTemplateValue.SafeText("secret-value")),
|
||||
manifest)))
|
||||
.isInstanceOf(TemplateRenderingException.class)
|
||||
.hasMessageContaining("bound")
|
||||
.hasMessageNotContaining("secret-value");
|
||||
}
|
||||
|
||||
@Test
|
||||
void slackBuilderEscapesMrkdwnPlainTextAndBroadcastMentions() {
|
||||
NotificationTemplateCatalog catalog = validCatalog(2_000);
|
||||
NotificationTemplateManifest manifest = catalog.require(SLACK_REF);
|
||||
NotificationFrozenPlan plan =
|
||||
slackPlan(
|
||||
Map.of(
|
||||
"displayName", new NotificationTemplateValue.SafeText("*Ada* <admin>"),
|
||||
"audience", new NotificationTemplateValue.SafeText("<!channel> & guests"),
|
||||
"mention", new NotificationTemplateValue.SafeText("@here <@U123>")),
|
||||
manifest);
|
||||
|
||||
RenderedNotification.Slack rendered = new SlackBlockKitRenderer(catalog).render(plan);
|
||||
|
||||
assertThat(rendered.blocks()).hasSize(3);
|
||||
assertThat(rendered.blocks().get(0).type()).isEqualTo(RenderedNotification.TextType.MRKDWN);
|
||||
assertThat(rendered.blocks().get(0).verbatim()).isTrue();
|
||||
assertThat(rendered.blocks().get(0).text())
|
||||
.contains("\\*Ada\\* <admin>")
|
||||
.doesNotContain("<admin>");
|
||||
assertThat(rendered.blocks().get(1).type()).isEqualTo(RenderedNotification.TextType.PLAIN_TEXT);
|
||||
assertThat(rendered.blocks().get(1).text())
|
||||
.contains("<!channel> & guests")
|
||||
.doesNotContain("<!channel>");
|
||||
assertThat(rendered.blocks().get(2).text())
|
||||
.contains("@\u200Bhere <@\u200BU123>")
|
||||
.doesNotContain("<@U123>");
|
||||
assertThat(rendered.maximumDepth()).isLessThanOrEqualTo(8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void htmlContextsCannotCrossIntoActiveAttributesOrMarkup() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
LocalEmailRenderer.validateHtmlContexts(
|
||||
"<img onerror=\"{{displayName|html_text}}\">"))
|
||||
.isInstanceOf(TemplateRenderingException.class)
|
||||
.hasMessageContaining("attribute");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
LocalEmailRenderer.validateHtmlContexts(
|
||||
"<div style=\"{{displayName|html_attr}}\"></div>"))
|
||||
.isInstanceOf(TemplateRenderingException.class)
|
||||
.hasMessageContaining("attribute");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
LocalEmailRenderer.validateHtmlContexts(
|
||||
"<img src=\"https://example.invalid/{{query|url_component}}\">"))
|
||||
.isInstanceOf(TemplateRenderingException.class)
|
||||
.hasMessageContaining("attribute");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
LocalEmailRenderer.validateHtmlContexts(
|
||||
"<a href=\"https://{{query|url_component}}\">open</a>"))
|
||||
.isInstanceOf(TemplateRenderingException.class)
|
||||
.hasMessageContaining("HTTPS link");
|
||||
LocalEmailRenderer.validateHtmlContexts(
|
||||
"<p>{{displayName|html_text}}</p>"
|
||||
+ "<a title=\"{{displayName|html_attr}}\" "
|
||||
+ "href=\"https://example.invalid/?q={{query|url_component}}\">open</a>");
|
||||
}
|
||||
|
||||
@Test
|
||||
void includeDirectivesAndUnknownResourcesAreRejectedWithoutFileOrNetworkFallback() {
|
||||
String path = "notification/templates/email/unsafe.txt";
|
||||
ClassLoader loader =
|
||||
new ClassLoader(null) {
|
||||
@Override
|
||||
public InputStream getResourceAsStream(String name) {
|
||||
if (path.equals(name)) {
|
||||
return new ByteArrayInputStream(
|
||||
"{{> file:/etc/passwd}}".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
NotificationTemplateManifest.ResourceSet resources =
|
||||
NotificationTemplateManifest.ResourceSet.email(path, path, path);
|
||||
String checksum =
|
||||
NotificationTemplateCatalog.checksum(loader, Map.of(Locale.ENGLISH, resources));
|
||||
NotificationTemplateManifest manifest =
|
||||
new NotificationTemplateManifest(
|
||||
new NotificationTemplateRef("unsafe-email", 1),
|
||||
NotificationChannel.EMAIL,
|
||||
"email-renderer-r1",
|
||||
checksum,
|
||||
Set.of(Locale.ENGLISH),
|
||||
Locale.ENGLISH,
|
||||
Set.of(),
|
||||
1_000,
|
||||
Map.of(Locale.ENGLISH, resources));
|
||||
|
||||
assertThatThrownBy(() -> new NotificationTemplateCatalog(loader, List.of(manifest)))
|
||||
.isInstanceOf(TemplateRenderingException.class)
|
||||
.hasMessageContaining("directive")
|
||||
.hasMessageNotContaining("/etc/passwd");
|
||||
}
|
||||
|
||||
private static NotificationTemplateCatalog validCatalog(int emailMaximumBytes) {
|
||||
ClassLoader loader = NotificationTemplateRendererTest.class.getClassLoader();
|
||||
NotificationTemplateManifest.ResourceSet emailResources =
|
||||
NotificationTemplateManifest.ResourceSet.email(
|
||||
"notification/templates/email/contract-v1.subject.txt",
|
||||
"notification/templates/email/contract-v1.text.txt",
|
||||
"notification/templates/email/contract-v1.html");
|
||||
NotificationTemplateManifest.ResourceSet slackResources =
|
||||
NotificationTemplateManifest.ResourceSet.slack(
|
||||
"notification/templates/slack/contract-v1.txt");
|
||||
NotificationTemplateManifest email =
|
||||
new NotificationTemplateManifest(
|
||||
EMAIL_REF,
|
||||
NotificationChannel.EMAIL,
|
||||
"email-renderer-r1",
|
||||
NotificationTemplateCatalog.checksum(
|
||||
loader, Map.of(Locale.ENGLISH, emailResources, Locale.KOREAN, emailResources)),
|
||||
Set.of(Locale.ENGLISH, Locale.KOREAN),
|
||||
Locale.ENGLISH,
|
||||
Set.of("displayName", "query"),
|
||||
emailMaximumBytes,
|
||||
Map.of(Locale.ENGLISH, emailResources, Locale.KOREAN, emailResources));
|
||||
NotificationTemplateManifest slack =
|
||||
new NotificationTemplateManifest(
|
||||
SLACK_REF,
|
||||
NotificationChannel.SLACK,
|
||||
"slack-renderer-r1",
|
||||
NotificationTemplateCatalog.checksum(loader, Map.of(Locale.ENGLISH, slackResources)),
|
||||
Set.of(Locale.ENGLISH),
|
||||
Locale.ENGLISH,
|
||||
Set.of("displayName", "audience", "mention"),
|
||||
2_000,
|
||||
Map.of(Locale.ENGLISH, slackResources));
|
||||
return new NotificationTemplateCatalog(loader, List.of(slack, email));
|
||||
}
|
||||
|
||||
private static NotificationFrozenPlan emailPlan(
|
||||
Locale locale,
|
||||
Map<String, NotificationTemplateValue> values,
|
||||
NotificationTemplateManifest manifest) {
|
||||
return emailPlan(locale, values, manifest.checksum(), manifest.rendererRevision());
|
||||
}
|
||||
|
||||
private static NotificationFrozenPlan emailPlan(
|
||||
Locale locale,
|
||||
Map<String, NotificationTemplateValue> values,
|
||||
String checksum,
|
||||
String rendererRevision) {
|
||||
return plan(
|
||||
EMAIL_REF,
|
||||
NotificationChannel.EMAIL,
|
||||
locale,
|
||||
new EmailRecipientReference("recipient-ref-42"),
|
||||
values,
|
||||
checksum,
|
||||
rendererRevision);
|
||||
}
|
||||
|
||||
private static NotificationFrozenPlan slackPlan(
|
||||
Map<String, NotificationTemplateValue> values, NotificationTemplateManifest manifest) {
|
||||
return plan(
|
||||
SLACK_REF,
|
||||
NotificationChannel.SLACK,
|
||||
Locale.ENGLISH,
|
||||
new SlackAudienceReference("workspace-ref-42", "audience-ref-42"),
|
||||
values,
|
||||
manifest.checksum(),
|
||||
manifest.rendererRevision());
|
||||
}
|
||||
|
||||
private static NotificationFrozenPlan plan(
|
||||
NotificationTemplateRef templateRef,
|
||||
NotificationChannel channel,
|
||||
Locale locale,
|
||||
dev.caskeleton.application.notification.NotificationRecipientReference recipient,
|
||||
Map<String, NotificationTemplateValue> values,
|
||||
String checksum,
|
||||
String rendererRevision) {
|
||||
NotificationKindPolicy policy =
|
||||
new NotificationKindPolicy(
|
||||
new NotificationKindId("contract-notice"),
|
||||
channel,
|
||||
new NotificationRouteId(
|
||||
channel == NotificationChannel.EMAIL ? "email-route" : "slack-route"),
|
||||
templateRef,
|
||||
NotificationMode.DURABLE_ASYNC,
|
||||
NotificationAdmissionClass.TRANSACTIONAL,
|
||||
NotificationRouteStrategy.SINGLE,
|
||||
ConsentCheckMode.SNAPSHOT_AT_APPEND,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
channel == NotificationChannel.EMAIL ? 1 : 0,
|
||||
channel == NotificationChannel.EMAIL ? 2 : 1,
|
||||
Duration.ofHours(1));
|
||||
Instant now = Instant.parse("2026-07-29T00:00:00Z");
|
||||
NotificationIntentDraft draft =
|
||||
new NotificationIntentDraft(
|
||||
new NotificationIntentId("intent-42"),
|
||||
policy,
|
||||
locale,
|
||||
recipient,
|
||||
new NotificationTemplateParameters(values),
|
||||
"scope-42",
|
||||
"operation-42",
|
||||
Optional.empty(),
|
||||
"correlation-42",
|
||||
Optional.empty(),
|
||||
now,
|
||||
now.plusSeconds(60));
|
||||
return NotificationFrozenPlan.from(
|
||||
draft,
|
||||
locale,
|
||||
new NotificationFrozenPlan.BindingSnapshot(
|
||||
1,
|
||||
"a".repeat(64),
|
||||
checksum,
|
||||
rendererRevision,
|
||||
List.of(
|
||||
new NotificationFrozenPlan.FrozenTarget(
|
||||
0,
|
||||
"target-42",
|
||||
channel == NotificationChannel.EMAIL
|
||||
? "aws-ses-v2-durable-single-local-sns-v1"
|
||||
: "slack-web-api-durable-single-local-v1",
|
||||
"binding-r1",
|
||||
"credential-r1")),
|
||||
channel == NotificationChannel.EMAIL,
|
||||
Duration.ofSeconds(5)));
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
<p>Hello {{displayName|html_text}}.</p>
|
||||
<a href="https://example.invalid/contracts?q={{query|url_component}}">Open contract</a>
|
||||
+1
@@ -0,0 +1 @@
|
||||
Contract notice for {{displayName|header}}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
Hello {{displayName|text}}.
|
||||
Reference: {{query|text}}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
*Alert for {{displayName|mrkdwn}}*
|
||||
Audience: {{audience|plain}}
|
||||
Mention: {{mention|mention}}
|
||||
@@ -63,11 +63,20 @@ adapters implement application/domain ports directly and must not depend on this
|
||||
| Mode | Propagation | Isolation | Read-only |
|
||||
|---|---|---|---|
|
||||
| `inWrite` | `REQUIRED` | `READ_COMMITTED` | `false` |
|
||||
| `inRootWrite` | `REQUIRED` | `READ_COMMITTED` | `false` |
|
||||
| `inRead` | `REQUIRED` | `READ_COMMITTED` | `true` |
|
||||
| `inNew` | `REQUIRES_NEW` | `READ_COMMITTED` | `false` |
|
||||
|
||||
Pre-built templates are immutable after construction so concurrent callers cannot
|
||||
observe each other's reconfiguration.
|
||||
observe each other's reconfiguration. `inRootWrite` reuses the pre-built write template,
|
||||
but first checks `TransactionSynchronizationManager.isActualTransactionActive()`.
|
||||
When an actual ambient transaction exists it MUST throw
|
||||
`NestedRootTransactionRejectedException` before invoking either the action or the
|
||||
`PlatformTransactionManager`. It MUST NOT use `NEVER` or `REQUIRES_NEW`.
|
||||
|
||||
`inRootWrite` returns its action value only after `TransactionTemplate.execute` has
|
||||
committed. A commit failure propagates the transaction exception and no success value
|
||||
is returned to the caller.
|
||||
|
||||
### `inNew` pool-sizing constraint (D12 of feature-application-port-usecase-contract)
|
||||
|
||||
|
||||
@@ -17,6 +17,19 @@ readOnly 를 바꿔 쓰면 같은 빈을 공유하는 동시 요청 사이에 ra
|
||||
사라지고, 각 모드를 따로 감사(audit)할 수 있다. 세 템플릿 모두 isolation 을 `READ_COMMITTED`
|
||||
로 고정한다(모드표는 CLAUDE.md §TransactionPort implementation contract).
|
||||
|
||||
### 왜 `inRootWrite`가 별도 템플릿이나 `NEVER` propagation을 만들지 않나
|
||||
`inRootWrite`의 실행 속성은 `inWrite`와 같은 `WRITE + REQUIRED + READ_COMMITTED`라 기존
|
||||
write template을 재사용한다. 차이는 실행 전 precondition이다.
|
||||
`TransactionSynchronizationManager.isActualTransactionActive()`가 `true`이면 action과
|
||||
`PlatformTransactionManager`를 호출하기 전에
|
||||
`NestedRootTransactionRejectedException`으로 fail-fast한다. `REQUIRES_NEW`로 suspend해서
|
||||
"root처럼 보이게" 하지 않으므로 호출자 transaction과 독립 commit되는 silent 의미 변경이 없다.
|
||||
|
||||
`TransactionTemplate.execute`는 commit까지 성공한 다음 값을 반환한다. 따라서
|
||||
`inRootWrite`의 결과는 post-commit에만 호출자에게 보이고, commit 실패는 값 대신 원래 transaction
|
||||
예외로 전파된다. 이 보장은 action이 외부 객체를 직접 변경하는 것을 되돌리는 보상이 아니라,
|
||||
경계의 반환값을 성공으로 노출하지 않는 계약이다.
|
||||
|
||||
## audit — `AuditableEntity` / `AuditContextPort` / `DomainContextAuditContextPort`
|
||||
|
||||
### 캡처 메커니즘 — Manual explicit-set (D1 현재 스켈레톤 기본값)
|
||||
|
||||
@@ -18,6 +18,9 @@ dependencies {
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
|
||||
testImplementation 'org.testcontainers:testcontainers-postgresql'
|
||||
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
|
||||
@@ -10,6 +10,9 @@ com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClass
|
||||
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml:classmate:1.7.1=runtimeClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.docker-java:docker-java-api:3.7.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.docker-java:docker-java-transport-zerodep:3.7.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.docker-java:docker-java-transport:3.7.0=testCompileClasspath,testRuntimeClasspath
|
||||
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=compileClasspath,testCompileClasspath
|
||||
@@ -41,7 +44,9 @@ com.sun.istack:istack-commons-runtime:4.1.2=runtimeClasspath,testRuntimeClasspat
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
com.zaxxer:HikariCP:7.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-codec:commons-codec:1.19.0=testCompileClasspath,testRuntimeClasspath
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
@@ -60,12 +65,14 @@ javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
net.java.dev.jna:jna:5.18.1=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
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-compress:1.28.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
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
|
||||
@@ -102,6 +109,7 @@ org.hibernate.models:hibernate-models:1.0.1=runtimeClasspath,testRuntimeClasspat
|
||||
org.hibernate.orm:hibernate-core:7.1.8.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jboss.logging:jboss-logging:3.6.1.Final=runtimeClasspath,testRuntimeClasspath
|
||||
org.jetbrains:annotations:17.0.0=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
|
||||
@@ -130,6 +138,7 @@ org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.postgresql:postgresql:42.7.8=runtimeClasspath,testRuntimeClasspath
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.rnorth.duct-tape:duct-tape:1.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.skyscreamer:jsonassert:1.5.3=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
|
||||
@@ -187,6 +196,11 @@ org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-tx:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.testcontainers:testcontainers-database-commons:2.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.testcontainers:testcontainers-jdbc:2.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.testcontainers:testcontainers-junit-jupiter:2.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.testcontainers:testcontainers-postgresql:2.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.testcontainers:testcontainers:2.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
|
||||
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/** Direct AES-256-GCM field encryption with a fresh 96-bit nonce and 128-bit tag. */
|
||||
public final class DirectAeadNotificationPayloadCrypto {
|
||||
|
||||
private static final String ALGORITHM = "AES-256-GCM";
|
||||
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
|
||||
private static final String PROFILE = "notification-direct-aead-v1";
|
||||
private static final String AAD_REVISION = "notification-aad-v1";
|
||||
private static final int TAG_BITS = 128;
|
||||
|
||||
private final NotificationKeyMaterialProvider keys;
|
||||
private final SecureRandom random;
|
||||
|
||||
public DirectAeadNotificationPayloadCrypto(
|
||||
NotificationKeyMaterialProvider keys, SecureRandom random) {
|
||||
this.keys = Objects.requireNonNull(keys, "notification key provider must be non-null");
|
||||
this.random = Objects.requireNonNull(random, "notification secure random must be non-null");
|
||||
}
|
||||
|
||||
public NotificationCiphertext encrypt(
|
||||
byte[] plaintext,
|
||||
NotificationCiphertext.AadContext context,
|
||||
String keyReference,
|
||||
String keyVersion) {
|
||||
Objects.requireNonNull(plaintext, "notification plaintext must be non-null");
|
||||
Objects.requireNonNull(context, "notification AAD context must be non-null");
|
||||
if (plaintext.length < 1 || plaintext.length > 10_000_000) {
|
||||
throw new IllegalArgumentException(
|
||||
"notification plaintext must contain 1..10000000 bytes");
|
||||
}
|
||||
requireProfile(context);
|
||||
byte[] nonce = new byte[12];
|
||||
random.nextBytes(nonce);
|
||||
try (NotificationKeyMaterialHandle handle = keys.acquire(keyReference, keyVersion)) {
|
||||
byte[] encrypted =
|
||||
handle.readBytes(
|
||||
material -> transform(Cipher.ENCRYPT_MODE, material, nonce, context, plaintext));
|
||||
return new NotificationCiphertext(
|
||||
ALGORITHM,
|
||||
handle.keyReference(),
|
||||
handle.keyVersion(),
|
||||
PROFILE,
|
||||
AAD_REVISION,
|
||||
nonce,
|
||||
encrypted);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] decrypt(
|
||||
NotificationCiphertext encrypted, NotificationCiphertext.AadContext context) {
|
||||
Objects.requireNonNull(encrypted, "notification ciphertext must be non-null");
|
||||
Objects.requireNonNull(context, "notification AAD context must be non-null");
|
||||
requireProfile(context);
|
||||
if (!ALGORITHM.equals(encrypted.algorithm())
|
||||
|| !PROFILE.equals(encrypted.cryptoProfileVersion())
|
||||
|| !AAD_REVISION.equals(encrypted.aadRevision())) {
|
||||
throw new NotificationCryptoException(
|
||||
"notification ciphertext cryptographic profile is unsupported");
|
||||
}
|
||||
try (NotificationKeyMaterialHandle handle =
|
||||
keys.acquire(encrypted.keyReference(), encrypted.keyVersion())) {
|
||||
return handle.readBytes(
|
||||
material ->
|
||||
transform(
|
||||
Cipher.DECRYPT_MODE,
|
||||
material,
|
||||
encrypted.nonce(),
|
||||
context,
|
||||
encrypted.ciphertext()));
|
||||
} catch (NotificationCryptoException failure) {
|
||||
throw failure;
|
||||
} catch (RuntimeException failure) {
|
||||
throw new NotificationCryptoException(
|
||||
"notification ciphertext authentication failed", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] transform(
|
||||
int mode,
|
||||
byte[] material,
|
||||
byte[] nonce,
|
||||
NotificationCiphertext.AadContext context,
|
||||
byte[] input) {
|
||||
if (material.length != 32) {
|
||||
throw new NotificationCryptoException(
|
||||
"notification AES-256 key revision has an invalid length");
|
||||
}
|
||||
byte[] keyCopy = material.clone();
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||
cipher.init(
|
||||
mode,
|
||||
new SecretKeySpec(keyCopy, "AES"),
|
||||
new GCMParameterSpec(TAG_BITS, nonce));
|
||||
cipher.updateAAD(context.canonicalBytes());
|
||||
return cipher.doFinal(input);
|
||||
} catch (GeneralSecurityException failure) {
|
||||
throw new NotificationCryptoException(
|
||||
"notification ciphertext authentication failed", failure);
|
||||
} finally {
|
||||
Arrays.fill(keyCopy, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireProfile(NotificationCiphertext.AadContext context) {
|
||||
if (!PROFILE.equals(context.cryptoProfileVersion())) {
|
||||
throw new NotificationCryptoException(
|
||||
"notification AAD crypto profile is unsupported");
|
||||
}
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Non-secret AES-GCM metadata and ciphertext; mutable arrays are defensively copied. */
|
||||
public record NotificationCiphertext(
|
||||
String algorithm,
|
||||
String keyReference,
|
||||
String keyVersion,
|
||||
String cryptoProfileVersion,
|
||||
String aadRevision,
|
||||
byte[] nonce,
|
||||
byte[] ciphertext) {
|
||||
|
||||
public NotificationCiphertext {
|
||||
if (!"AES-256-GCM".equals(algorithm)) {
|
||||
throw new IllegalArgumentException("notification ciphertext algorithm must be AES-256-GCM");
|
||||
}
|
||||
keyReference =
|
||||
NotificationKeyMaterialHandle.requireSlug(
|
||||
"notification ciphertext key reference", keyReference);
|
||||
keyVersion =
|
||||
NotificationKeyMaterialHandle.requireSlug(
|
||||
"notification ciphertext key version", keyVersion);
|
||||
cryptoProfileVersion =
|
||||
NotificationKeyMaterialHandle.requireSlug(
|
||||
"notification crypto profile version", cryptoProfileVersion);
|
||||
aadRevision =
|
||||
NotificationKeyMaterialHandle.requireSlug(
|
||||
"notification AAD revision", aadRevision);
|
||||
Objects.requireNonNull(nonce, "notification ciphertext nonce must be non-null");
|
||||
Objects.requireNonNull(ciphertext, "notification ciphertext bytes must be non-null");
|
||||
if (nonce.length != 12 || ciphertext.length < 17 || ciphertext.length > 10_000_016) {
|
||||
throw new IllegalArgumentException(
|
||||
"notification ciphertext nonce/tag/payload bounds are invalid");
|
||||
}
|
||||
nonce = nonce.clone();
|
||||
ciphertext = ciphertext.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] nonce() {
|
||||
return nonce.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] ciphertext() {
|
||||
return ciphertext.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) {
|
||||
return true;
|
||||
}
|
||||
if (!(other instanceof NotificationCiphertext that)) {
|
||||
return false;
|
||||
}
|
||||
return algorithm.equals(that.algorithm)
|
||||
&& keyReference.equals(that.keyReference)
|
||||
&& keyVersion.equals(that.keyVersion)
|
||||
&& cryptoProfileVersion.equals(that.cryptoProfileVersion)
|
||||
&& aadRevision.equals(that.aadRevision)
|
||||
&& Arrays.equals(nonce, that.nonce)
|
||||
&& Arrays.equals(ciphertext, that.ciphertext);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result =
|
||||
Objects.hash(
|
||||
algorithm, keyReference, keyVersion, cryptoProfileVersion, aadRevision);
|
||||
result = 31 * result + Arrays.hashCode(nonce);
|
||||
return 31 * result + Arrays.hashCode(ciphertext);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "NotificationCiphertext[algorithm="
|
||||
+ algorithm
|
||||
+ ", keyReference="
|
||||
+ keyReference
|
||||
+ ", keyVersion="
|
||||
+ keyVersion
|
||||
+ ", cryptoProfileVersion="
|
||||
+ cryptoProfileVersion
|
||||
+ ", aadRevision="
|
||||
+ aadRevision
|
||||
+ ", nonce=<redacted>, ciphertext=<redacted>]";
|
||||
}
|
||||
|
||||
/** Exact approved length-prefixed AAD hierarchy for one encrypted notification field. */
|
||||
public record AadContext(
|
||||
String schemaTable,
|
||||
String recordId,
|
||||
String notificationId,
|
||||
Optional<String> deliveryId,
|
||||
Optional<String> attemptId,
|
||||
String fieldPurpose,
|
||||
String providerBindingRevision,
|
||||
String cryptoProfileVersion) {
|
||||
|
||||
public AadContext {
|
||||
if (schemaTable == null || !schemaTable.matches("[a-z][a-z0-9_]{0,62}")) {
|
||||
throw new IllegalArgumentException(
|
||||
"notification AAD table must match [a-z][a-z0-9_]{0,62}");
|
||||
}
|
||||
recordId = requireOpaque("notification AAD record ID", recordId);
|
||||
notificationId = requireOpaque("notification AAD notification ID", notificationId);
|
||||
deliveryId = requireOptional("notification AAD delivery ID", deliveryId);
|
||||
attemptId = requireOptional("notification AAD attempt ID", attemptId);
|
||||
fieldPurpose =
|
||||
NotificationKeyMaterialHandle.requireSlug(
|
||||
"notification AAD field purpose", fieldPurpose);
|
||||
providerBindingRevision =
|
||||
NotificationKeyMaterialHandle.requireSlug(
|
||||
"notification AAD provider binding revision", providerBindingRevision);
|
||||
cryptoProfileVersion =
|
||||
NotificationKeyMaterialHandle.requireSlug(
|
||||
"notification AAD crypto profile", cryptoProfileVersion);
|
||||
}
|
||||
|
||||
byte[] canonicalBytes() {
|
||||
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
|
||||
update(output, schemaTable);
|
||||
update(output, recordId);
|
||||
update(output, notificationId);
|
||||
updateOptional(output, deliveryId);
|
||||
updateOptional(output, attemptId);
|
||||
update(output, fieldPurpose);
|
||||
update(output, providerBindingRevision);
|
||||
update(output, cryptoProfileVersion);
|
||||
return output.toByteArray();
|
||||
}
|
||||
|
||||
private static Optional<String> requireOptional(
|
||||
String field, Optional<String> value) {
|
||||
Objects.requireNonNull(value, field + " container must be non-null");
|
||||
return value.map(item -> requireOpaque(field, item));
|
||||
}
|
||||
|
||||
private static String requireOpaque(String field, String value) {
|
||||
if (value == null || !value.matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")) {
|
||||
throw new IllegalArgumentException(
|
||||
field + " must contain 1..128 opaque identifier characters");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void update(java.io.ByteArrayOutputStream output, String value) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
output.writeBytes(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
|
||||
output.writeBytes(bytes);
|
||||
}
|
||||
|
||||
private static void updateOptional(
|
||||
java.io.ByteArrayOutputStream output, Optional<String> value) {
|
||||
output.write(value.isPresent() ? 1 : 0);
|
||||
value.ifPresent(item -> update(output, item));
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
|
||||
|
||||
/** Redacted fail-closed notification cryptographic operation error. */
|
||||
public final class NotificationCryptoException extends RuntimeException {
|
||||
|
||||
public NotificationCryptoException(String safeMessage) {
|
||||
super(safeMessage);
|
||||
}
|
||||
|
||||
public NotificationCryptoException(String safeMessage, Throwable cause) {
|
||||
super(safeMessage, cause);
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/** Purpose-separated length-prefixed HMAC-SHA-256 over non-secret canonical tuple fields. */
|
||||
public final class NotificationHmacDigester {
|
||||
|
||||
private static final String ALGORITHM = "HmacSHA256";
|
||||
private static final int MAXIMUM_VERIFICATION_VERSIONS = 4;
|
||||
|
||||
private final NotificationKeyMaterialProvider keys;
|
||||
|
||||
public NotificationHmacDigester(NotificationKeyMaterialProvider keys) {
|
||||
this.keys = Objects.requireNonNull(keys, "notification HMAC key provider must be non-null");
|
||||
}
|
||||
|
||||
public Digest digest(
|
||||
String purpose,
|
||||
List<String> fields,
|
||||
String keyReference,
|
||||
String keyVersion) {
|
||||
byte[] canonical = canonical(purpose, fields);
|
||||
try (NotificationKeyMaterialHandle handle = keys.acquire(keyReference, keyVersion)) {
|
||||
String value =
|
||||
handle.readBytes(
|
||||
material -> HexFormat.of().formatHex(hmac(material, canonical)));
|
||||
return new Digest(handle.keyReference(), handle.keyVersion(), value);
|
||||
} finally {
|
||||
Arrays.fill(canonical, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean verify(Digest expected, String purpose, List<String> fields) {
|
||||
Objects.requireNonNull(expected, "expected notification HMAC must be non-null");
|
||||
List<String> versions =
|
||||
List.copyOf(
|
||||
Objects.requireNonNull(
|
||||
keys.verificationVersions(expected.keyReference()),
|
||||
"notification HMAC verification versions must be non-null"));
|
||||
if (versions.isEmpty()
|
||||
|| versions.size() > MAXIMUM_VERIFICATION_VERSIONS
|
||||
|| new HashSet<>(versions).size() != versions.size()) {
|
||||
throw new NotificationCryptoException(
|
||||
"notification HMAC verification key set must be unique and bounded");
|
||||
}
|
||||
if (!versions.contains(expected.keyVersion())) {
|
||||
return false;
|
||||
}
|
||||
Digest actual =
|
||||
digest(purpose, fields, expected.keyReference(), expected.keyVersion());
|
||||
return MessageDigest.isEqual(
|
||||
HexFormat.of().parseHex(expected.value()),
|
||||
HexFormat.of().parseHex(actual.value()));
|
||||
}
|
||||
|
||||
private static byte[] canonical(String purpose, List<String> fields) {
|
||||
if (purpose == null || !purpose.matches("[a-z][a-z0-9-]{0,62}")) {
|
||||
throw new IllegalArgumentException(
|
||||
"notification HMAC purpose must match [a-z][a-z0-9-]{0,62}");
|
||||
}
|
||||
Objects.requireNonNull(fields, "notification HMAC fields must be non-null");
|
||||
if (fields.isEmpty() || fields.size() > 32) {
|
||||
throw new IllegalArgumentException("notification HMAC fields must contain 1..32 entries");
|
||||
}
|
||||
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
|
||||
update(output, purpose);
|
||||
output.writeBytes(ByteBuffer.allocate(Integer.BYTES).putInt(fields.size()).array());
|
||||
fields.forEach(
|
||||
field -> {
|
||||
if (field == null || field.length() > 4_096) {
|
||||
throw new IllegalArgumentException(
|
||||
"notification HMAC field must contain at most 4096 characters");
|
||||
}
|
||||
update(output, field);
|
||||
});
|
||||
return output.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] hmac(byte[] material, byte[] canonical) {
|
||||
if (material.length < 32) {
|
||||
throw new NotificationCryptoException(
|
||||
"notification HMAC key revision has an invalid length");
|
||||
}
|
||||
byte[] keyCopy = material.clone();
|
||||
try {
|
||||
Mac mac = Mac.getInstance(ALGORITHM);
|
||||
mac.init(new SecretKeySpec(keyCopy, ALGORITHM));
|
||||
return mac.doFinal(canonical);
|
||||
} catch (GeneralSecurityException failure) {
|
||||
throw new NotificationCryptoException("notification HMAC operation failed", failure);
|
||||
} finally {
|
||||
Arrays.fill(keyCopy, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static void update(java.io.ByteArrayOutputStream output, String value) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
output.writeBytes(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
|
||||
output.writeBytes(bytes);
|
||||
}
|
||||
|
||||
public record Digest(String keyReference, String keyVersion, String value) {
|
||||
|
||||
public Digest {
|
||||
keyReference =
|
||||
NotificationKeyMaterialHandle.requireSlug(
|
||||
"notification HMAC key reference", keyReference);
|
||||
keyVersion =
|
||||
NotificationKeyMaterialHandle.requireSlug(
|
||||
"notification HMAC key version", keyVersion);
|
||||
if (value == null || !value.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException(
|
||||
"notification HMAC value must be lowercase SHA-256 hex");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Digest[keyReference="
|
||||
+ keyReference
|
||||
+ ", keyVersion="
|
||||
+ keyVersion
|
||||
+ ", value=<redacted>]";
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** Operation-scoped mutable key copy that wipes on close and rejects use after close. */
|
||||
public final class NotificationKeyMaterialHandle implements AutoCloseable {
|
||||
|
||||
private final String keyReference;
|
||||
private final String keyVersion;
|
||||
private final byte[] material;
|
||||
private boolean closed;
|
||||
|
||||
private NotificationKeyMaterialHandle(
|
||||
String keyReference, String keyVersion, byte[] material) {
|
||||
this.keyReference = requireSlug("notification key reference", keyReference);
|
||||
this.keyVersion = requireSlug("notification key version", keyVersion);
|
||||
this.material = material;
|
||||
}
|
||||
|
||||
public static NotificationKeyMaterialHandle fromBytes(
|
||||
String keyReference, String keyVersion, byte[] material) {
|
||||
Objects.requireNonNull(material, "notification key material must be non-null");
|
||||
if (material.length < 32 || material.length > 65_536) {
|
||||
throw new IllegalArgumentException(
|
||||
"notification key material must contain 32..65536 bytes");
|
||||
}
|
||||
return new NotificationKeyMaterialHandle(keyReference, keyVersion, material.clone());
|
||||
}
|
||||
|
||||
public String keyReference() {
|
||||
return keyReference;
|
||||
}
|
||||
|
||||
public String keyVersion() {
|
||||
return keyVersion;
|
||||
}
|
||||
|
||||
public synchronized <T> T readBytes(Function<byte[], T> reader) {
|
||||
Objects.requireNonNull(reader, "notification key reader must be non-null");
|
||||
if (closed) {
|
||||
throw new IllegalStateException("notification key material handle is closed");
|
||||
}
|
||||
return reader.apply(material);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
if (!closed) {
|
||||
Arrays.fill(material, (byte) 0);
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized String toString() {
|
||||
return "NotificationKeyMaterialHandle[keyReference="
|
||||
+ keyReference
|
||||
+ ", keyVersion="
|
||||
+ keyVersion
|
||||
+ ", material=<redacted>, closed="
|
||||
+ closed
|
||||
+ "]";
|
||||
}
|
||||
|
||||
static String requireSlug(String field, String value) {
|
||||
if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) {
|
||||
throw new IllegalArgumentException(field + " must match [a-z][a-z0-9.-]{0,62}");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Acquires versioned mutable key copies and declares bounded current-plus-retiring verification. */
|
||||
public interface NotificationKeyMaterialProvider {
|
||||
|
||||
NotificationKeyMaterialHandle acquire(String keyReference, String keyVersion);
|
||||
|
||||
List<String> verificationVersions(String keyReference);
|
||||
}
|
||||
+10
@@ -1,12 +1,14 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.transaction;
|
||||
|
||||
import dev.caskeleton.application.transaction.Isolation;
|
||||
import dev.caskeleton.application.transaction.NestedRootTransactionRejectedException;
|
||||
import dev.caskeleton.application.transaction.TransactionMode;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import java.util.function.Supplier;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
@@ -47,6 +49,14 @@ public class SpringTransactionPort implements TransactionPort {
|
||||
return writeTemplate.execute(status -> action.get());
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T inRootWrite(Supplier<T> action) {
|
||||
if (TransactionSynchronizationManager.isActualTransactionActive()) {
|
||||
throw new NestedRootTransactionRejectedException();
|
||||
}
|
||||
return writeTemplate.execute(status -> action.get());
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T inRead(Supplier<T> action) {
|
||||
return readTemplate.execute(status -> action.get());
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class NotificationHmacDigesterTest {
|
||||
|
||||
@Test
|
||||
void purposeAndLengthPrefixesSeparateAmbiguousTuplesAndOrdering() {
|
||||
NotificationHmacDigester digester =
|
||||
new NotificationHmacDigester(NotificationPayloadCryptoTest.InMemoryKeys.standard());
|
||||
|
||||
NotificationHmacDigester.Digest split =
|
||||
digester.digest("intent-dedupe-v1", List.of("a", "bc"), "alias-key", "hmac-r2");
|
||||
NotificationHmacDigester.Digest joined =
|
||||
digester.digest("intent-dedupe-v1", List.of("ab", "c"), "alias-key", "hmac-r2");
|
||||
NotificationHmacDigester.Digest reordered =
|
||||
digester.digest("intent-dedupe-v1", List.of("bc", "a"), "alias-key", "hmac-r2");
|
||||
NotificationHmacDigester.Digest otherPurpose =
|
||||
digester.digest("recipient-alias-v1", List.of("a", "bc"), "alias-key", "hmac-r2");
|
||||
|
||||
assertThat(split.value()).matches("[0-9a-f]{64}");
|
||||
assertThat(split.value())
|
||||
.isNotEqualTo(joined.value())
|
||||
.isNotEqualTo(reordered.value())
|
||||
.isNotEqualTo(otherPurpose.value());
|
||||
assertThat(split.toString()).doesNotContain(split.value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentAndBoundedRetiringVersionsVerifyWhileUnknownAndOversizedSetsFailClosed() {
|
||||
NotificationHmacDigester digester =
|
||||
new NotificationHmacDigester(NotificationPayloadCryptoTest.InMemoryKeys.standard());
|
||||
NotificationHmacDigester.Digest retiring =
|
||||
digester.digest("intent-dedupe-v1", List.of("tenant-42", "scope-42"), "alias-key", "hmac-r1");
|
||||
|
||||
assertThat(digester.verify(retiring, "intent-dedupe-v1", List.of("tenant-42", "scope-42")))
|
||||
.isTrue();
|
||||
assertThat(digester.verify(retiring, "recipient-alias-v1", List.of("tenant-42", "scope-42")))
|
||||
.isFalse();
|
||||
assertThat(
|
||||
digester.verify(
|
||||
new NotificationHmacDigester.Digest(
|
||||
"alias-key", "hmac-r9", retiring.value()),
|
||||
"intent-dedupe-v1",
|
||||
List.of("tenant-42", "scope-42")))
|
||||
.isFalse();
|
||||
|
||||
NotificationKeyMaterialProvider oversized =
|
||||
new NotificationKeyMaterialProvider() {
|
||||
@Override
|
||||
public NotificationKeyMaterialHandle acquire(String reference, String version) {
|
||||
return NotificationKeyMaterialHandle.fromBytes(
|
||||
reference,
|
||||
version,
|
||||
NotificationPayloadCryptoTest.InMemoryKeys.HMAC_R2);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> verificationVersions(String reference) {
|
||||
return List.of("hmac-r1", "hmac-r2", "hmac-r3", "hmac-r4", "hmac-r5");
|
||||
}
|
||||
};
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationHmacDigester(oversized)
|
||||
.verify(
|
||||
retiring,
|
||||
"intent-dedupe-v1",
|
||||
List.of("tenant-42", "scope-42")))
|
||||
.isInstanceOf(NotificationCryptoException.class)
|
||||
.hasMessageContaining("bounded");
|
||||
}
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class NotificationPayloadCryptoTest {
|
||||
|
||||
@Test
|
||||
void aes256GcmUsesFreshNonceExactMetadataAndContextBoundLengthPrefixedAad() {
|
||||
InMemoryKeys keys = InMemoryKeys.standard();
|
||||
DirectAeadNotificationPayloadCrypto crypto =
|
||||
new DirectAeadNotificationPayloadCrypto(keys, new SecureRandom());
|
||||
NotificationCiphertext.AadContext context = context("record-42", "parameters");
|
||||
byte[] plaintext = "recipient-secret-value".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
NotificationCiphertext first =
|
||||
crypto.encrypt(plaintext, context, "payload-key", "payload-r2");
|
||||
NotificationCiphertext second =
|
||||
crypto.encrypt(plaintext, context, "payload-key", "payload-r2");
|
||||
|
||||
assertThat(first.algorithm()).isEqualTo("AES-256-GCM");
|
||||
assertThat(first.cryptoProfileVersion()).isEqualTo("notification-direct-aead-v1");
|
||||
assertThat(first.aadRevision()).isEqualTo("notification-aad-v1");
|
||||
assertThat(first.nonce()).hasSize(12).isNotEqualTo(second.nonce());
|
||||
assertThat(crypto.decrypt(first, context)).isEqualTo(plaintext);
|
||||
assertThat(first.toString())
|
||||
.doesNotContain("recipient-secret-value")
|
||||
.doesNotContain(Arrays.toString(InMemoryKeys.PAYLOAD_R2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tupleFieldSwapsCiphertextSwapsAndWrongKeyRevisionFailAuthenticationAndStayRedacted() {
|
||||
InMemoryKeys keys = InMemoryKeys.standard();
|
||||
DirectAeadNotificationPayloadCrypto crypto =
|
||||
new DirectAeadNotificationPayloadCrypto(keys, new SecureRandom());
|
||||
NotificationCiphertext.AadContext parameters = context("record-42", "parameters");
|
||||
NotificationCiphertext.AadContext recipient = context("record-43", "recipient");
|
||||
NotificationCiphertext encrypted =
|
||||
crypto.encrypt(
|
||||
"secret-A".getBytes(StandardCharsets.UTF_8),
|
||||
parameters,
|
||||
"payload-key",
|
||||
"payload-r2");
|
||||
NotificationCiphertext other =
|
||||
crypto.encrypt(
|
||||
"secret-B".getBytes(StandardCharsets.UTF_8),
|
||||
recipient,
|
||||
"payload-key",
|
||||
"payload-r2");
|
||||
|
||||
assertThatThrownBy(() -> crypto.decrypt(encrypted, recipient))
|
||||
.isInstanceOf(NotificationCryptoException.class)
|
||||
.hasMessageContaining("authentication")
|
||||
.hasMessageNotContaining("secret-A")
|
||||
.hasMessageNotContaining("secret-B");
|
||||
NotificationCiphertext swapped =
|
||||
new NotificationCiphertext(
|
||||
encrypted.algorithm(),
|
||||
encrypted.keyReference(),
|
||||
encrypted.keyVersion(),
|
||||
encrypted.cryptoProfileVersion(),
|
||||
encrypted.aadRevision(),
|
||||
other.nonce(),
|
||||
other.ciphertext());
|
||||
assertThatThrownBy(() -> crypto.decrypt(swapped, parameters))
|
||||
.isInstanceOf(NotificationCryptoException.class)
|
||||
.hasMessageContaining("authentication");
|
||||
NotificationCiphertext wrongVersion =
|
||||
new NotificationCiphertext(
|
||||
encrypted.algorithm(),
|
||||
encrypted.keyReference(),
|
||||
"payload-r1",
|
||||
encrypted.cryptoProfileVersion(),
|
||||
encrypted.aadRevision(),
|
||||
encrypted.nonce(),
|
||||
encrypted.ciphertext());
|
||||
assertThatThrownBy(() -> crypto.decrypt(wrongVersion, parameters))
|
||||
.isInstanceOf(NotificationCryptoException.class)
|
||||
.hasMessageNotContaining(Arrays.toString(InMemoryKeys.PAYLOAD_R1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acquiredMutableKeyHandleWipesOnCloseAndRejectsUseAfterClose() {
|
||||
AtomicReference<byte[]> borrowed = new AtomicReference<>();
|
||||
NotificationKeyMaterialHandle handle =
|
||||
NotificationKeyMaterialHandle.fromBytes(
|
||||
"payload-key", "payload-r2", InMemoryKeys.PAYLOAD_R2);
|
||||
handle.readBytes(
|
||||
bytes -> {
|
||||
borrowed.set(bytes);
|
||||
return null;
|
||||
});
|
||||
|
||||
handle.close();
|
||||
|
||||
assertThat(borrowed.get()).containsOnly((byte) 0);
|
||||
assertThatThrownBy(() -> handle.readBytes(bytes -> bytes.length))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("closed");
|
||||
assertThat(handle.toString()).doesNotContain(Arrays.toString(InMemoryKeys.PAYLOAD_R2));
|
||||
}
|
||||
|
||||
private static NotificationCiphertext.AadContext context(String recordId, String purpose) {
|
||||
return new NotificationCiphertext.AadContext(
|
||||
"notification_intent",
|
||||
recordId,
|
||||
"intent-42",
|
||||
Optional.of("delivery-42"),
|
||||
Optional.of("attempt-42"),
|
||||
purpose,
|
||||
"provider-binding-r3",
|
||||
"notification-direct-aead-v1");
|
||||
}
|
||||
|
||||
static final class InMemoryKeys implements NotificationKeyMaterialProvider {
|
||||
|
||||
static final byte[] PAYLOAD_R1 = "1".repeat(32).getBytes(StandardCharsets.US_ASCII);
|
||||
static final byte[] PAYLOAD_R2 = "2".repeat(32).getBytes(StandardCharsets.US_ASCII);
|
||||
static final byte[] HMAC_R1 = "3".repeat(32).getBytes(StandardCharsets.US_ASCII);
|
||||
static final byte[] HMAC_R2 = "4".repeat(32).getBytes(StandardCharsets.US_ASCII);
|
||||
|
||||
private final Map<String, byte[]> keys;
|
||||
private final Map<String, List<String>> verificationVersions;
|
||||
|
||||
private InMemoryKeys(
|
||||
Map<String, byte[]> keys, Map<String, List<String>> verificationVersions) {
|
||||
this.keys = keys;
|
||||
this.verificationVersions = verificationVersions;
|
||||
}
|
||||
|
||||
static InMemoryKeys standard() {
|
||||
return new InMemoryKeys(
|
||||
Map.of(
|
||||
"payload-key:payload-r1",
|
||||
PAYLOAD_R1,
|
||||
"payload-key:payload-r2",
|
||||
PAYLOAD_R2,
|
||||
"alias-key:hmac-r1",
|
||||
HMAC_R1,
|
||||
"alias-key:hmac-r2",
|
||||
HMAC_R2),
|
||||
Map.of("alias-key", List.of("hmac-r2", "hmac-r1")));
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationKeyMaterialHandle acquire(String keyReference, String keyVersion) {
|
||||
byte[] key = keys.get(keyReference + ":" + keyVersion);
|
||||
if (key == null) {
|
||||
throw new NotificationCryptoException("notification key revision is unavailable");
|
||||
}
|
||||
return NotificationKeyMaterialHandle.fromBytes(keyReference, keyVersion, key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> verificationVersions(String keyReference) {
|
||||
return verificationVersions.getOrDefault(keyReference, List.of());
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -1,18 +1,30 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.transaction;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.transaction.NestedRootTransactionRejectedException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.TransactionException;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.transaction.TransactionSystemException;
|
||||
import org.springframework.transaction.support.SimpleTransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
class SpringTransactionPortTest {
|
||||
|
||||
@AfterEach
|
||||
void clearTransactionState() {
|
||||
TransactionSynchronizationManager.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void inWriteUsesRequiredPropagationReadCommittedIsolationAndNotReadOnly() {
|
||||
RecordingTransactionManager tm = new RecordingTransactionManager();
|
||||
@@ -48,6 +60,88 @@ class SpringTransactionPortTest {
|
||||
assertThat(definition.isReadOnly()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void inRootWriteUsesRequiredPropagationReadCommittedIsolationAndNotReadOnly() {
|
||||
RecordingTransactionManager tm = new RecordingTransactionManager();
|
||||
SpringTransactionPort port = new SpringTransactionPort(tm);
|
||||
|
||||
String result = port.inRootWrite(() -> "ok");
|
||||
|
||||
assertThat(result).isEqualTo("ok");
|
||||
assertThat(tm.definitions).hasSize(1);
|
||||
TransactionDefinition definition = tm.definitions.get(0);
|
||||
assertThat(definition.getPropagationBehavior())
|
||||
.isEqualTo(TransactionDefinition.PROPAGATION_REQUIRED);
|
||||
assertThat(definition.getIsolationLevel())
|
||||
.isEqualTo(TransactionDefinition.ISOLATION_READ_COMMITTED);
|
||||
assertThat(definition.isReadOnly()).isFalse();
|
||||
assertThat(tm.commits).isOne();
|
||||
assertThat(tm.rollbacks).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void inRootWriteRejectsAmbientActualTransactionBeforeActionOrTransactionManagerSideEffects() {
|
||||
RecordingTransactionManager tm = new RecordingTransactionManager();
|
||||
SpringTransactionPort port = new SpringTransactionPort(tm);
|
||||
AtomicBoolean actionCalled = new AtomicBoolean();
|
||||
TransactionSynchronizationManager.setActualTransactionActive(true);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
port.inRootWrite(
|
||||
() -> {
|
||||
actionCalled.set(true);
|
||||
return "not-visible";
|
||||
}))
|
||||
.isInstanceOf(NestedRootTransactionRejectedException.class);
|
||||
|
||||
assertThat(actionCalled).isFalse();
|
||||
assertThat(tm.definitions).isEmpty();
|
||||
assertThat(tm.commitAttempts).isZero();
|
||||
assertThat(tm.rollbacks).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void inRootWriteReturnsOnlyAfterPhysicalCommitCompletes() {
|
||||
RecordingTransactionManager tm = new RecordingTransactionManager();
|
||||
SpringTransactionPort port = new SpringTransactionPort(tm);
|
||||
|
||||
String result =
|
||||
port.inRootWrite(
|
||||
() -> {
|
||||
tm.lifecycle.add("action");
|
||||
return "committed";
|
||||
});
|
||||
tm.lifecycle.add("returned");
|
||||
|
||||
assertThat(result).isEqualTo("committed");
|
||||
assertThat(tm.lifecycle).containsExactly("begin", "action", "commit", "returned");
|
||||
}
|
||||
|
||||
@Test
|
||||
void inRootWritePropagatesCommitFailureWithoutPublishingCallerVisibleResult() {
|
||||
RecordingTransactionManager tm = new RecordingTransactionManager();
|
||||
tm.failCommit = true;
|
||||
SpringTransactionPort port = new SpringTransactionPort(tm);
|
||||
AtomicReference<String> callerVisible = new AtomicReference<>();
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
callerVisible.set(
|
||||
port.inRootWrite(
|
||||
() -> {
|
||||
tm.lifecycle.add("action");
|
||||
return "must-not-be-visible";
|
||||
})))
|
||||
.isInstanceOf(TransactionSystemException.class)
|
||||
.hasMessageContaining("commit failed");
|
||||
|
||||
assertThat(callerVisible).hasValue(null);
|
||||
assertThat(tm.commitAttempts).isOne();
|
||||
assertThat(tm.commits).isZero();
|
||||
assertThat(tm.lifecycle).containsExactly("begin", "action", "commit-failed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void inNewUsesRequiresNewPropagationReadCommittedIsolationAndNotReadOnly() {
|
||||
RecordingTransactionManager tm = new RecordingTransactionManager();
|
||||
@@ -116,6 +210,9 @@ class SpringTransactionPortTest {
|
||||
private static final class RecordingTransactionManager implements PlatformTransactionManager {
|
||||
|
||||
private final List<TransactionDefinition> definitions = new ArrayList<>();
|
||||
private final List<String> lifecycle = new ArrayList<>();
|
||||
private boolean failCommit;
|
||||
private int commitAttempts;
|
||||
private int commits;
|
||||
private int rollbacks;
|
||||
|
||||
@@ -123,11 +220,18 @@ class SpringTransactionPortTest {
|
||||
public TransactionStatus getTransaction(TransactionDefinition definition)
|
||||
throws TransactionException {
|
||||
definitions.add(definition);
|
||||
lifecycle.add("begin");
|
||||
return new SimpleTransactionStatus(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void commit(TransactionStatus status) throws TransactionException {
|
||||
commitAttempts++;
|
||||
if (failCommit) {
|
||||
lifecycle.add("commit-failed");
|
||||
throw new TransactionSystemException("commit failed");
|
||||
}
|
||||
lifecycle.add("commit");
|
||||
commits++;
|
||||
}
|
||||
|
||||
|
||||
+17
-1
@@ -6,6 +6,7 @@ import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import com.tngtech.archunit.lang.EvaluationResult;
|
||||
import dev.caskeleton.application.architecture.violations.ApplicationDiagnosticFrameworkViolation;
|
||||
import dev.caskeleton.bootstrap.architecture.fixtures.application.RootWriteTransactionBoundaryUseCase;
|
||||
import dev.caskeleton.bootstrap.architecture.allowed.application.CleanProjectionQueryPort;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.application.BulkWriteWithoutWriteAccessUseCase;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.application.FixtureRepository;
|
||||
@@ -59,6 +60,8 @@ class ArchitectureViolationFixtureTest {
|
||||
new ClassFileImporter().importClasses(JakartaValidationApplicationFixture.class);
|
||||
private static final JavaClasses APPLICATION_DIAGNOSTIC_FRAMEWORK_FIXTURE_ONLY =
|
||||
new ClassFileImporter().importClasses(ApplicationDiagnosticFrameworkViolation.class);
|
||||
private static final JavaClasses ROOT_WRITE_TRANSACTION_BOUNDARY_FIXTURE_ONLY =
|
||||
new ClassFileImporter().importClasses(RootWriteTransactionBoundaryUseCase.class);
|
||||
|
||||
// Each WebSocket fixture is imported in ISOLATION so the two package globs in
|
||||
// NO_WEBSOCKET_HANDLER ("org.springframework.web.socket.." vs "jakarta.websocket..")
|
||||
@@ -261,10 +264,23 @@ class ArchitectureViolationFixtureTest {
|
||||
.as(
|
||||
"USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY must catch "
|
||||
+ "MissingTransactionBoundaryUseCase declaring WRITE_REPOSITORY without "
|
||||
+ "TransactionPort.inWrite")
|
||||
+ "TransactionPort.inWrite or TransactionPort.inRootWrite")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void useCaseCapabilityMatchesTransactionPortBoundaryAllowsRootWriteBoundary() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY.evaluate(
|
||||
ROOT_WRITE_TRANSACTION_BOUNDARY_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY must allow a "
|
||||
+ "WRITE_REPOSITORY use case that directly calls TransactionPort.inRootWrite")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedContractScopeRuleCatchesDomainSpecificSharedPackage() {
|
||||
EvaluationResult result =
|
||||
|
||||
+24
-12
@@ -387,7 +387,7 @@ class CleanArchitectureTest {
|
||||
"feature-domain-feature-onboarding-contract D4: a use case that declares a "
|
||||
+ "repository-backed transaction capability must call the matching "
|
||||
+ "TransactionPort boundary directly: READ_REPOSITORY+READ_ONLY -> inRead, "
|
||||
+ "WRITE_REPOSITORY+WRITE -> inWrite, REQUIRES_NEW -> inNew. "
|
||||
+ "WRITE_REPOSITORY+WRITE -> inWrite or inRootWrite, REQUIRES_NEW -> inNew. "
|
||||
+ "RepositoryAccess.NONE may intentionally skip a DB transaction. "
|
||||
+ "UNSUPPORTED_IMPL_DECISION: static analysis reaches direct calls only; a "
|
||||
+ "transaction hidden behind a helper remains a code-review concern.")
|
||||
@@ -583,24 +583,24 @@ class CleanArchitectureTest {
|
||||
String transactionMode = enumAnnotationValue(annotation, "transactionMode");
|
||||
String repositoryAccess = enumAnnotationValue(annotation, "repositoryAccess");
|
||||
|
||||
String requiredMethod = null;
|
||||
Set<String> requiredMethods = Set.of();
|
||||
if ("REQUIRES_NEW".equals(transactionMode)) {
|
||||
requiredMethod = "inNew";
|
||||
requiredMethods = Set.of("inNew");
|
||||
} else if ("WRITE".equals(transactionMode) && "WRITE_REPOSITORY".equals(repositoryAccess)) {
|
||||
requiredMethod = "inWrite";
|
||||
requiredMethods = Set.of("inWrite", "inRootWrite");
|
||||
} else if ("READ_ONLY".equals(transactionMode)
|
||||
&& "READ_REPOSITORY".equals(repositoryAccess)) {
|
||||
requiredMethod = "inRead";
|
||||
requiredMethods = Set.of("inRead");
|
||||
}
|
||||
|
||||
if (requiredMethod == null) {
|
||||
if (requiredMethods.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (JavaMethodCall call : item.getMethodCallsFromSelf()) {
|
||||
if ("dev.caskeleton.application.transaction.TransactionPort"
|
||||
.equals(call.getTargetOwner().getFullName())
|
||||
&& requiredMethod.equals(call.getName())) {
|
||||
&& requiredMethods.contains(call.getName())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -614,8 +614,8 @@ class CleanArchitectureTest {
|
||||
+ transactionMode
|
||||
+ ", repositoryAccess = "
|
||||
+ repositoryAccess
|
||||
+ ") but does not directly call TransactionPort."
|
||||
+ requiredMethod
|
||||
+ ") but does not directly call one of TransactionPort."
|
||||
+ requiredMethods
|
||||
+ "(...)"));
|
||||
}
|
||||
};
|
||||
@@ -1231,8 +1231,9 @@ class CleanArchitectureTest {
|
||||
static final ArchRule OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES =
|
||||
methods()
|
||||
.that()
|
||||
.areDeclaredInClassesThat()
|
||||
.resideInAPackage("..adapter.outbound..")
|
||||
.areDeclaredInClassesThat(
|
||||
JavaClass.Predicates.resideInAPackage("..adapter.outbound..")
|
||||
.and(implementsApplicationLayerPort()))
|
||||
// @Configuration factory classes are excluded: a @Bean method legitimately
|
||||
// returns the adapter's own outbound port type for DI wiring (e.g.
|
||||
// MessagingConfig#messagePublisher -> MessagePublisher) — that is
|
||||
@@ -1263,7 +1264,7 @@ class CleanArchitectureTest {
|
||||
"..adapter.inbound.web..",
|
||||
"..adapter.outbound.persistence.."))
|
||||
.as(
|
||||
"B7: outbound adapter public methods must return domain types (or "
|
||||
"B7: outbound application-port adapter public methods must return domain types (or "
|
||||
+ "primitives/wrappers/Optional) — raw external response types must not "
|
||||
+ "escape the adapter package "
|
||||
+ "(feature-boundary-validation-mapping-contract B7 ACL). @Configuration "
|
||||
@@ -1272,6 +1273,17 @@ class CleanArchitectureTest {
|
||||
+ "response surfaces.")
|
||||
.allowEmptyShould(true);
|
||||
|
||||
private static DescribedPredicate<JavaClass> implementsApplicationLayerPort() {
|
||||
return DescribedPredicate.describe(
|
||||
"implement an application-layer port",
|
||||
type ->
|
||||
type.getAllRawInterfaces().stream()
|
||||
.anyMatch(
|
||||
iface ->
|
||||
iface.getPackageName().contains(".application.")
|
||||
|| iface.getPackageName().endsWith(".application")));
|
||||
}
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule VALID_CASCADE_DEPTH_AT_MOST_THREE =
|
||||
classes()
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.caskeleton.bootstrap.architecture.fixtures.application;
|
||||
|
||||
import dev.caskeleton.application.capability.Idempotency;
|
||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||
import dev.caskeleton.application.command.Command;
|
||||
import dev.caskeleton.application.security.RequiresPermission;
|
||||
import dev.caskeleton.application.transaction.TransactionMode;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import dev.caskeleton.application.usecase.CommandUseCase;
|
||||
|
||||
/** Positive fixture: a root-only write boundary satisfies the WRITE_REPOSITORY fitness rule. */
|
||||
@UseCaseCapability(
|
||||
transactionMode = TransactionMode.WRITE,
|
||||
idempotency = Idempotency.NOT_IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||
@RequiresPermission("fixture:root-write")
|
||||
public final class RootWriteTransactionBoundaryUseCase
|
||||
implements CommandUseCase<RootWriteTransactionBoundaryUseCase.CommandFixture, String> {
|
||||
|
||||
private final TransactionPort transactionPort;
|
||||
|
||||
public RootWriteTransactionBoundaryUseCase(TransactionPort transactionPort) {
|
||||
this.transactionPort = transactionPort;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String handle(CommandFixture command) {
|
||||
return transactionPort.inRootWrite(command::value);
|
||||
}
|
||||
|
||||
public record CommandFixture(String value) implements Command {}
|
||||
}
|
||||
+4
-1
@@ -1,11 +1,14 @@
|
||||
package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound;
|
||||
|
||||
import dev.caskeleton.bootstrap.architecture.violations.application.RawLeakPortFixture;
|
||||
|
||||
/**
|
||||
* Negative fixture for {@code OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES}. Returns a
|
||||
* class still inside the outbound adapter package — the ACL bypass the contract forbids (B7).
|
||||
*/
|
||||
public class RawTypeLeakingAdapterFixture {
|
||||
public class RawTypeLeakingAdapterFixture implements RawLeakPortFixture {
|
||||
|
||||
@Override
|
||||
public RawExternalResponseFixture leakRaw() {
|
||||
return new RawExternalResponseFixture();
|
||||
}
|
||||
|
||||
+4
-1
@@ -8,7 +8,10 @@ import dev.caskeleton.application.security.RequiresPermission;
|
||||
import dev.caskeleton.application.transaction.TransactionMode;
|
||||
import dev.caskeleton.application.usecase.CommandUseCase;
|
||||
|
||||
/** Intentional write-use-case violation: declares a write but skips TransactionPort.inWrite. */
|
||||
/**
|
||||
* Intentional write-use-case violation: declares a write but skips both TransactionPort.inWrite and
|
||||
* TransactionPort.inRootWrite.
|
||||
*/
|
||||
@UseCaseCapability(
|
||||
transactionMode = TransactionMode.WRITE,
|
||||
idempotency = Idempotency.NOT_IDEMPOTENT,
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package dev.caskeleton.bootstrap.architecture.violations.application;
|
||||
|
||||
/** Application-port side of the B7 raw adapter response negative fixture. */
|
||||
public interface RawLeakPortFixture {
|
||||
|
||||
Object leakRaw();
|
||||
}
|
||||
@@ -56,13 +56,37 @@ Package root: `dev.caskeleton.application`.
|
||||
| `usecase.QueryUseCase<Q extends Query, R>` | Inbound port for read-only use cases. Implementations MUST declare `transactionMode = READ_ONLY` and `repositoryAccess = READ_REPOSITORY`. |
|
||||
| `command.Command` | Marker for write intents. Plain immutable types built from domain values. |
|
||||
| `query.Query` | Marker for read intents. Plain immutable types built from domain values. |
|
||||
| `transaction.TransactionPort` | Outbound port for transactional boundaries. Implemented by `adapter-persistence`. |
|
||||
| `transaction.TransactionPort` | Outbound port for join-capable write/read, physical root-only write, and independent write boundaries. Implemented by `adapter-persistence`. |
|
||||
| `transaction.NestedRootTransactionRejectedException` | Fail-fast signal raised before action/provider side effects when `inRootWrite` detects an actual ambient transaction. |
|
||||
| `transaction.TransactionMode` | `WRITE` / `READ_ONLY` / `REQUIRES_NEW`. `NESTED` and `NEVER` are intentionally absent. |
|
||||
| `transaction.Isolation` | `READ_COMMITTED` (pinned default) / `REPEATABLE_READ` / `SERIALIZABLE`. `READ_UNCOMMITTED` is forbidden (not declared); the vendor default is never used (engine defaults differ — PostgreSQL READ COMMITTED vs MySQL InnoDB REPEATABLE READ). Routing the stricter levels through `TransactionPort` is a `planned` joint change with `feature-application-port-usecase-contract`; the shipped call path pins `READ_COMMITTED`. |
|
||||
| `capability.UseCaseCapability` | Mandatory annotation on every concrete use case: declares `transactionMode`, `idempotency`, `repositoryAccess`, `externalOutboundAllowed`. |
|
||||
| `capability.Idempotency` | `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`. |
|
||||
| `capability.RepositoryAccess` | `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`. |
|
||||
|
||||
## Notification R1 application boundary
|
||||
|
||||
- `dev.caskeleton.application.notification` owns only framework-free semantic values, code-owned
|
||||
kind policy, narrow outbound ports, dispatch/receipt/admission/reconciliation orchestration and
|
||||
writer-cutover command contracts.
|
||||
- Feature/application code creates a typed `NotificationIntentDraft`; `NotificationPlanPort`
|
||||
returns the application-owned immutable `NotificationFrozenPlan`, which is the only planning
|
||||
handoff consumed by append or inline attempt ports. Provider SDK, transport DTO, persistence
|
||||
entity, compiled adapter binding and raw recipient/template payload types are forbidden here.
|
||||
- Provider calls run outside database transactions. Dispatch and reconciliation use bounded
|
||||
claim/authorize/finalize transactions with opaque claim/version/execution tokens; an
|
||||
`INDETERMINATE` submission is terminal and must not be blindly retried.
|
||||
- Receipt reduction is order-independent and keeps delivery acceptance monotonic. Only hard bounce
|
||||
and complaint facts may request technical suppression; consent/unsubscribe policy is outside this
|
||||
capability.
|
||||
- Writer-cutover operations that must prove a physical commit use `inRootWrite`. Route/profile
|
||||
registries are application-owned exact inputs; signed inventory/quiescence verification is
|
||||
delegated to narrow verifier ports and the persistence operation must enforce locked durable
|
||||
state/journal invariants.
|
||||
- This is the R1 application contract proven with fakes. It does not claim PostgreSQL schema/locking,
|
||||
provider protocol, cryptographic verifier, or runtime wiring qualification; those belong to the
|
||||
notification/persistence/bootstrap adapters.
|
||||
|
||||
## Naming convention
|
||||
|
||||
- Inbound port implementations end with `UseCase` (e.g. `RegisterUserUseCase`). Enforced by ArchUnit.
|
||||
@@ -103,10 +127,15 @@ application-core never self-registers with a DI framework.
|
||||
| Use case shape | `transactionMode` | TransactionPort call | When |
|
||||
|---|---|---|---|
|
||||
| Write command | `WRITE` | `tx.inWrite(...)` | Default for `CommandUseCase`. |
|
||||
| Physical-root write command | `WRITE` | `tx.inRootWrite(...)` | Only when orchestration must prove there is no ambient transaction and expose a result after commit. |
|
||||
| Read-only query | `READ_ONLY` | `tx.inRead(...)` | Default for `QueryUseCase`. |
|
||||
| Outbox / audit / compensation | `REQUIRES_NEW` | `tx.inNew(...)` | Only when the use case MUST commit independently of the caller. |
|
||||
|
||||
`NESTED` and `NEVER` propagation are forbidden.
|
||||
`inRootWrite` MUST reject an actual ambient transaction before invoking its action or
|
||||
`PlatformTransactionManager`; it MUST NOT emulate root-only behavior with `REQUIRES_NEW`.
|
||||
Both `inWrite` and `inRootWrite` satisfy the direct boundary fitness rule for a
|
||||
`WRITE_REPOSITORY + WRITE` use case. READ and REQUIRES_NEW mappings remain exclusive.
|
||||
|
||||
### Callback signature contract (D11)
|
||||
|
||||
|
||||
@@ -71,13 +71,21 @@
|
||||
- **존재 이유**: application 유스케이스가 `org.springframework.transaction.annotation.Transactional`
|
||||
을 import 하지 않고도 트랜잭션 의도를 선언하게 하기 위한 추상화다. 구현(보통
|
||||
`SpringTransactionPort`)은 persistence adapter 가 Spring `PlatformTransactionManager` 로 제공한다.
|
||||
application/domain 을 프레임워크-free 로 유지하는 핵심 장치.
|
||||
- 세 가지 경계:
|
||||
application/domain 을 프레임워크-free 로 유지하는 핵심 장치.
|
||||
- 네 가지 경계:
|
||||
- `inWrite` — REQUIRED + read-write, `READ_COMMITTED`. command 유스케이스 기본.
|
||||
- `inRootWrite` — 물리 root 전용 REQUIRED + read-write, `READ_COMMITTED`. 실제 ambient
|
||||
transaction 이 하나라도 있으면 action 실행 전에
|
||||
`NestedRootTransactionRejectedException` 으로 거부한다. 성공 값은 commit 이 끝난 뒤에만
|
||||
호출자에게 반환되며, commit 실패는 그대로 전파된다.
|
||||
- `inRead` — REQUIRED + read-only, `READ_COMMITTED`. query 유스케이스 기본.
|
||||
- `inNew` — REQUIRES_NEW + read-write. UseCaseCapability 에 `REQUIRES_NEW` 를 명시한
|
||||
유스케이스(outbox/audit/compensation)에서만 허용.
|
||||
- **콜백 시그니처(D11)**: 세 메서드 모두 `Supplier`/`Runnable` 을 받아 checked exception 을 던질
|
||||
- **root-only 사용 조건**: `inRootWrite` 는 join 가능한 일반 command 경계의 대체물이 아니다.
|
||||
외부 효과를 commit 이후에만 시작해야 하는 orchestration처럼 물리 root를 증명해야 하는 경우에만
|
||||
쓴다. 기존 transaction 안에서 `REQUIRES_NEW` 로 몰래 분리하지 않고 fail-fast하므로, 호출자는
|
||||
transaction 없는 진입점에서 이 경계를 시작해야 한다.
|
||||
- **콜백 시그니처(D11)**: 네 메서드 모두 `Supplier`/`Runnable` 을 받아 checked exception 을 던질
|
||||
수 없다. Spring `TransactionCallback<T>` 제약과 동일하다. 그래서 호출자는 도메인 checked
|
||||
exception 을 `RuntimeException` 하위로 감싸야 한다(`DomainException extends RuntimeException`).
|
||||
`IOException` → `UncheckedIOException`, `SQLException` 은 Spring `DataAccessException` 계층이
|
||||
@@ -93,7 +101,33 @@
|
||||
**금지**: 많은 레코드를 도는 루프 안에서 `inNew` 호출(예: per-row outbox dispatch). 풀 고갈 +
|
||||
데드락 위험. 레코드를 한 번의 `inNew` 안에서 배치 처리하거나, 루프를 트랜잭션 경계 밖으로 빼라.
|
||||
- **금지 목록**: `NESTED`/`NEVER` propagation, `READ_UNCOMMITTED` isolation, application 패키지에서
|
||||
`@Transactional` 직접 사용, `inNew` 의 per-record 루프 호출.
|
||||
`@Transactional` 직접 사용, `inRootWrite` 의 ambient transaction 진입, `inNew` 의 per-record
|
||||
루프 호출.
|
||||
|
||||
---
|
||||
|
||||
## Notification R1 오케스트레이션 경계
|
||||
|
||||
`dev.caskeleton.application.notification`은 알림 vendor 구현이 아니라 알림 capability의 순수
|
||||
애플리케이션 계약이다.
|
||||
|
||||
- 입력은 typed recipient/template value와 코드 소유 `NotificationKindPolicy`로 제한한다. feature가
|
||||
만든 `NotificationIntentDraft`는 `NotificationPlanPort`에서 immutable
|
||||
`NotificationFrozenPlan`으로 고정되고, append/inline 포트는 이 plan만 소비한다.
|
||||
- dispatch는 claim → reserve/authorize → provider call → terminal-once finalize 순서다. 짧은 DB
|
||||
transaction 사이에서 provider를 호출하며, opaque claim/version/execution token으로 stale 결과를
|
||||
거부한다. submission certainty가 `INDETERMINATE`면 blind retry나 fallback을 하지 않는다.
|
||||
- receipt reducer는 fact 순서와 무관한 monotonic projection을 만든다. hard bounce/complaint만
|
||||
technical suppression 후보이고, business consent/unsubscribe는 다른 capability가 소유한다.
|
||||
- admission/reconciliation/maintenance는 bounded batch와 주입된 `Clock`을 사용한다. scheduler는
|
||||
이 유스케이스만 호출하며 store/provider 포트를 직접 조율하지 않는다.
|
||||
- legacy→canonical writer cutover는 exact route/generation/profile registry, root-only commit,
|
||||
서명된 inventory/quiescence evidence와 closed transition action으로 표현한다. 애플리케이션은
|
||||
verifier/operation 포트의 입력 계약을 강제하고, 실제 서명 검증·행 잠금·불변 journal·provider
|
||||
egress 차단은 후속 adapter 구현이 증명해야 한다.
|
||||
|
||||
현재 증거 등급은 **R1 application contract with fakes**다. PostgreSQL DDL/locking, provider
|
||||
protocol, receipt ingress, runtime wiring을 포함한 R2/R3 완료 주장이 아니다.
|
||||
|
||||
### TransactionMode
|
||||
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import dev.caskeleton.application.command.Command;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Applies one already authenticated and normalized receipt. */
|
||||
public record ApplyNotificationReceiptCommand(NormalizedNotificationReceiptCommand receipt)
|
||||
implements Command {
|
||||
|
||||
public ApplyNotificationReceiptCommand {
|
||||
Objects.requireNonNull(receipt, "normalized notification receipt must be non-null");
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Non-sensitive result of reducing one receipt event. */
|
||||
public record ApplyNotificationReceiptResult(
|
||||
Status status, NotificationReceiptProjection projection, boolean suppressionApplied) {
|
||||
|
||||
public ApplyNotificationReceiptResult {
|
||||
Objects.requireNonNull(status, "notification receipt apply status must be non-null");
|
||||
Objects.requireNonNull(projection, "notification receipt projection must be non-null");
|
||||
if (status == Status.DUPLICATE && suppressionApplied) {
|
||||
throw new IllegalArgumentException("duplicate receipt cannot repeat technical suppression");
|
||||
}
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
APPLIED,
|
||||
DUPLICATE
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import dev.caskeleton.application.capability.Idempotency;
|
||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||
import dev.caskeleton.application.security.RequiresPermission;
|
||||
import dev.caskeleton.application.transaction.TransactionMode;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import dev.caskeleton.application.usecase.CommandUseCase;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Appends one receipt fact and reduces its delivery projection in one physical root transaction.
|
||||
*/
|
||||
@RequiresPermission("notification:receipt")
|
||||
@UseCaseCapability(
|
||||
transactionMode = TransactionMode.WRITE,
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||
public final class ApplyNotificationReceiptUseCase
|
||||
implements CommandUseCase<ApplyNotificationReceiptCommand, ApplyNotificationReceiptResult> {
|
||||
|
||||
private final NotificationReceiptStorePort store;
|
||||
private final NotificationTechnicalSuppressionPort suppression;
|
||||
private final TransactionPort transactions;
|
||||
private final Clock clock;
|
||||
|
||||
public ApplyNotificationReceiptUseCase(
|
||||
NotificationReceiptStorePort store,
|
||||
NotificationTechnicalSuppressionPort suppression,
|
||||
TransactionPort transactions,
|
||||
Clock clock) {
|
||||
this.store = Objects.requireNonNull(store, "notification receipt store must be non-null");
|
||||
this.suppression =
|
||||
Objects.requireNonNull(suppression, "notification suppression port must be non-null");
|
||||
this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null");
|
||||
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApplyNotificationReceiptResult handle(ApplyNotificationReceiptCommand command) {
|
||||
Objects.requireNonNull(command, "apply notification receipt command must be non-null");
|
||||
return transactions.inRootWrite(() -> applyInsideRoot(command.receipt()));
|
||||
}
|
||||
|
||||
private ApplyNotificationReceiptResult applyInsideRoot(
|
||||
NormalizedNotificationReceiptCommand command) {
|
||||
NotificationReceiptStorePort.AppendResult appendResult = store.appendIfAbsent(command);
|
||||
if (appendResult instanceof NotificationReceiptStorePort.Duplicate duplicate) {
|
||||
return new ApplyNotificationReceiptResult(
|
||||
ApplyNotificationReceiptResult.Status.DUPLICATE, duplicate.projection(), false);
|
||||
}
|
||||
|
||||
NotificationReceiptStorePort.ReceiptAggregate aggregate =
|
||||
((NotificationReceiptStorePort.Appended) appendResult).aggregate();
|
||||
if (!aggregate.deliveryId().equals(command.deliveryId())) {
|
||||
throw new IllegalStateException(
|
||||
"receipt aggregate delivery does not match normalized command");
|
||||
}
|
||||
NotificationReceiptProjection projection =
|
||||
NotificationReceiptProjection.reduce(aggregate.facts());
|
||||
store.saveProjection(aggregate.deliveryId(), projection);
|
||||
|
||||
boolean suppressionApplied = shouldSuppress(command.fact());
|
||||
if (suppressionApplied) {
|
||||
suppression.suppress(
|
||||
new NotificationTechnicalSuppressionPort.SuppressionMutation(
|
||||
aggregate.recipient(), command.fact().reasonCode(), clock.instant()));
|
||||
}
|
||||
return new ApplyNotificationReceiptResult(
|
||||
ApplyNotificationReceiptResult.Status.APPLIED, projection, suppressionApplied);
|
||||
}
|
||||
|
||||
private static boolean shouldSuppress(NotificationReceiptFact fact) {
|
||||
return fact.type() == NotificationReceiptFact.Type.COMPLAINT
|
||||
|| (fact.type() == NotificationReceiptFact.Type.BOUNCE
|
||||
&& fact.bounceClass() == NotificationReceiptFact.BounceClass.HARD);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
/** Point at which recipient consent or preference must be established. */
|
||||
public enum ConsentCheckMode {
|
||||
SNAPSHOT_AT_APPEND,
|
||||
RECHECK_BEFORE_EACH_DELIVERY
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
/** Opaque reference resolved to an email recipient only inside a qualified adapter. */
|
||||
public record EmailRecipientReference(String reference) implements NotificationRecipientReference {
|
||||
|
||||
public EmailRecipientReference {
|
||||
reference = NotificationIntentId.requireOpaque("email recipient reference", reference);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationChannel channel() {
|
||||
return NotificationChannel.EMAIL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "EmailRecipientReference[reference=<redacted>]";
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import dev.caskeleton.application.command.Command;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Reviewed all-route initialization request; partial route sets are rejected by the use case. */
|
||||
public record InitializeNotificationWriterFencesCommand(
|
||||
String operationToken,
|
||||
NotificationCanonicalWriterRouteSet reviewedRoutes,
|
||||
String reviewedRouteSetDigest,
|
||||
String actorReference,
|
||||
NotificationReasonCode reasonCode)
|
||||
implements Command {
|
||||
|
||||
public InitializeNotificationWriterFencesCommand {
|
||||
operationToken =
|
||||
NotificationIntentId.requireOpaque("writer initialization operation token", operationToken);
|
||||
Objects.requireNonNull(reviewedRoutes, "reviewed writer routes must be non-null");
|
||||
reviewedRouteSetDigest = requireDigest(reviewedRouteSetDigest);
|
||||
actorReference =
|
||||
NotificationIntentId.requireOpaque("writer initialization actor", actorReference);
|
||||
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
|
||||
}
|
||||
|
||||
static String requireDigest(String digest) {
|
||||
if (digest == null || !digest.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException(
|
||||
"writer evidence digest must be 64 lowercase hex characters");
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** Atomic persistence operation for absent-fence and empty-journal initialization. */
|
||||
@FunctionalInterface
|
||||
public interface InitializeNotificationWriterFencesOperation {
|
||||
|
||||
InitializeNotificationWriterFencesResult initialize(
|
||||
InitializeNotificationWriterFencesCommand command,
|
||||
NotificationWriterRouteSet trustedRoutes,
|
||||
Instant requestedAt);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Committed all-route fence initialization result. */
|
||||
public record InitializeNotificationWriterFencesResult(
|
||||
Status status, int initializedRouteCount, String routeSetDigest) {
|
||||
|
||||
public InitializeNotificationWriterFencesResult {
|
||||
Objects.requireNonNull(status, "writer initialization status must be non-null");
|
||||
if (initializedRouteCount < 1 || initializedRouteCount > 100) {
|
||||
throw new IllegalArgumentException("initialized writer route count must be in 1..100");
|
||||
}
|
||||
routeSetDigest = InitializeNotificationWriterFencesCommand.requireDigest(routeSetDigest);
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
INITIALIZED,
|
||||
REPLAYED
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import dev.caskeleton.application.capability.Idempotency;
|
||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||
import dev.caskeleton.application.security.RequiresPermission;
|
||||
import dev.caskeleton.application.transaction.TransactionMode;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import dev.caskeleton.application.usecase.CommandUseCase;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Root-commits the complete trusted writer fence and proof registry initialization batch. */
|
||||
@RequiresPermission("notification:cutover")
|
||||
@UseCaseCapability(
|
||||
transactionMode = TransactionMode.WRITE,
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
|
||||
crossTenantAdmin = true)
|
||||
public final class InitializeNotificationWriterFencesUseCase
|
||||
implements CommandUseCase<
|
||||
InitializeNotificationWriterFencesCommand, InitializeNotificationWriterFencesResult> {
|
||||
|
||||
private final NotificationWriterRouteSet routes;
|
||||
private final InitializeNotificationWriterFencesOperation operation;
|
||||
private final TransactionPort transactions;
|
||||
private final Clock clock;
|
||||
|
||||
public InitializeNotificationWriterFencesUseCase(
|
||||
NotificationWriterRouteSet routes,
|
||||
InitializeNotificationWriterFencesOperation operation,
|
||||
TransactionPort transactions,
|
||||
Clock clock) {
|
||||
this.routes = Objects.requireNonNull(routes, "notification writer route set must be non-null");
|
||||
this.operation =
|
||||
Objects.requireNonNull(operation, "writer initialization operation must be non-null");
|
||||
this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null");
|
||||
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public InitializeNotificationWriterFencesResult handle(
|
||||
InitializeNotificationWriterFencesCommand command) {
|
||||
Objects.requireNonNull(command, "writer initialization command must be non-null");
|
||||
if (!command.reviewedRoutes().equals(routes.canonicalRoutes())) {
|
||||
throw new IllegalArgumentException(
|
||||
"reviewed writer routes must exactly equal the trusted all-route set");
|
||||
}
|
||||
if (!command.reviewedRouteSetDigest().equals(routes.digest())) {
|
||||
throw new IllegalArgumentException(
|
||||
"reviewed writer route-set digest does not match trusted registry digest");
|
||||
}
|
||||
return transactions.inRootWrite(() -> operation.initialize(command, routes, clock.instant()));
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
/**
|
||||
* Executes one bounded, non-durable inline attempt over a frozen application plan. The caller must
|
||||
* establish the physical root-write sequencing contract before invoking this port.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface InlineNotificationAttemptPort {
|
||||
|
||||
NotificationRequestResult.InlineCompleted attempt(NotificationFrozenPlan plan);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Framework-free receipt normalized by an authenticated inbound adapter. */
|
||||
public record NormalizedNotificationReceiptCommand(
|
||||
NotificationReceiptEventId receiptEventId,
|
||||
NotificationDeliveryId deliveryId,
|
||||
NotificationReceiptFact fact) {
|
||||
|
||||
public NormalizedNotificationReceiptCommand {
|
||||
Objects.requireNonNull(receiptEventId, "notification receipt event ID must be non-null");
|
||||
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
|
||||
Objects.requireNonNull(fact, "notification receipt fact must be non-null");
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
/** Code-owned dispatch admission and fairness class. */
|
||||
public enum NotificationAdmissionClass {
|
||||
SECURITY_CRITICAL,
|
||||
TRANSACTIONAL,
|
||||
BULK_LOW_VALUE
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import dev.caskeleton.application.command.Command;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Audited operator request to re-probe and resume one shared notification admission gate. */
|
||||
public record NotificationAdmissionGateCommand(
|
||||
String operationToken,
|
||||
NotificationRouteId routeId,
|
||||
int policyRevision,
|
||||
NotificationFaultScope faultScope,
|
||||
String scopeReference,
|
||||
long expectedGeneration,
|
||||
int maximumParkedLegs,
|
||||
String actorReference,
|
||||
NotificationReasonCode reasonCode)
|
||||
implements Command {
|
||||
|
||||
public NotificationAdmissionGateCommand {
|
||||
operationToken =
|
||||
NotificationIntentId.requireOpaque("admission resume operation token", operationToken);
|
||||
Objects.requireNonNull(routeId, "notification route ID must be non-null");
|
||||
if (policyRevision < 1 || expectedGeneration < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"policy revision must be positive and expected generation non-negative");
|
||||
}
|
||||
if (maximumParkedLegs < 1 || maximumParkedLegs > 100) {
|
||||
throw new IllegalArgumentException("maximum parked legs must be in 1..100");
|
||||
}
|
||||
Objects.requireNonNull(faultScope, "notification fault scope must be non-null");
|
||||
if (faultScope == NotificationFaultScope.DELIVERY) {
|
||||
throw new IllegalArgumentException("operator admission command cannot target DELIVERY scope");
|
||||
}
|
||||
scopeReference =
|
||||
NotificationIntentId.requireOpaque("admission scope reference", scopeReference);
|
||||
actorReference =
|
||||
NotificationIntentId.requireOpaque("admission resume actor reference", actorReference);
|
||||
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
|
||||
}
|
||||
|
||||
NotificationAdmissionReadinessPort.ResumeRequest toResumeRequest() {
|
||||
return new NotificationAdmissionReadinessPort.ResumeRequest(
|
||||
operationToken,
|
||||
routeId,
|
||||
policyRevision,
|
||||
faultScope,
|
||||
scopeReference,
|
||||
expectedGeneration,
|
||||
maximumParkedLegs,
|
||||
actorReference,
|
||||
reasonCode);
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import dev.caskeleton.application.capability.Idempotency;
|
||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||
import dev.caskeleton.application.security.RequiresPermission;
|
||||
import dev.caskeleton.application.transaction.TransactionMode;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import dev.caskeleton.application.usecase.CommandUseCase;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Probes readiness outside a transaction and generation-CAS resumes inside one short write. */
|
||||
@RequiresPermission("notification:operate")
|
||||
@UseCaseCapability(
|
||||
transactionMode = TransactionMode.WRITE,
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
|
||||
externalOutboundAllowed = true,
|
||||
crossTenantAdmin = true)
|
||||
public final class NotificationAdmissionGateUseCase
|
||||
implements CommandUseCase<
|
||||
NotificationAdmissionGateCommand, NotificationAdmissionGateUseCase.Result> {
|
||||
|
||||
private final NotificationAdmissionReadinessPort admission;
|
||||
private final TransactionPort transactions;
|
||||
private final Clock clock;
|
||||
|
||||
public NotificationAdmissionGateUseCase(
|
||||
NotificationAdmissionReadinessPort admission, TransactionPort transactions, Clock clock) {
|
||||
this.admission =
|
||||
Objects.requireNonNull(admission, "notification admission port must be non-null");
|
||||
this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null");
|
||||
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result handle(NotificationAdmissionGateCommand command) {
|
||||
Objects.requireNonNull(command, "notification admission command must be non-null");
|
||||
NotificationAdmissionReadinessPort.ResumeRequest request = command.toResumeRequest();
|
||||
NotificationAdmissionReadinessPort.ReadinessProbe probe =
|
||||
Objects.requireNonNull(admission.probe(request), "readiness probe must be non-null");
|
||||
if (!probe.ready()) {
|
||||
return new Result(Result.Status.NOT_READY, probe.reasonCode());
|
||||
}
|
||||
NotificationAdmissionReadinessPort.ResumeResult resumeResult =
|
||||
Objects.requireNonNull(
|
||||
transactions.inWrite(() -> admission.resume(request, probe, clock.instant())),
|
||||
"notification admission resume result must be non-null");
|
||||
validateResumeResult(request, resumeResult);
|
||||
return switch (resumeResult.status()) {
|
||||
case RESUMED -> new Result(Result.Status.RESUMED, probe.reasonCode());
|
||||
case ALREADY_ACTIVE -> new Result(Result.Status.ALREADY_ACTIVE, probe.reasonCode());
|
||||
case STALE_GENERATION ->
|
||||
new Result(
|
||||
Result.Status.STALE_GENERATION,
|
||||
new NotificationReasonCode("STALE_ADMISSION_GENERATION"));
|
||||
};
|
||||
}
|
||||
|
||||
private static void validateResumeResult(
|
||||
NotificationAdmissionReadinessPort.ResumeRequest request,
|
||||
NotificationAdmissionReadinessPort.ResumeResult result) {
|
||||
if (result.processedLegCount() > request.maximumParkedLegs()) {
|
||||
throw new IllegalArgumentException(
|
||||
"admission resume processed more parked legs than the requested bound");
|
||||
}
|
||||
if (result.status() == NotificationAdmissionReadinessPort.ResumeStatus.RESUMED
|
||||
&& result.resultingGeneration() != request.expectedGeneration() + 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"resumed admission gate must advance the exact expected generation");
|
||||
}
|
||||
}
|
||||
|
||||
public record Result(Status status, NotificationReasonCode reasonCode) {
|
||||
|
||||
public Result {
|
||||
Objects.requireNonNull(status, "notification admission result status must be non-null");
|
||||
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
RESUMED,
|
||||
ALREADY_ACTIVE,
|
||||
NOT_READY,
|
||||
STALE_GENERATION
|
||||
}
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Persists shared route/provider/account admission readiness with generation-guarded CAS. */
|
||||
@FunctionalInterface
|
||||
public interface NotificationAdmissionReadinessPort {
|
||||
|
||||
ParkResult park(ParkRequest request);
|
||||
|
||||
default ReadinessProbe probe(ResumeRequest request) {
|
||||
throw new UnsupportedOperationException("notification readiness probe is not implemented");
|
||||
}
|
||||
|
||||
default ResumeResult resume(ResumeRequest request, ReadinessProbe probe, Instant resumedAt) {
|
||||
throw new UnsupportedOperationException("notification admission resume is not implemented");
|
||||
}
|
||||
|
||||
record ParkRequest(
|
||||
NotificationRouteId routeId,
|
||||
int policyRevision,
|
||||
NotificationFaultScope faultScope,
|
||||
String scopeReference,
|
||||
long expectedGeneration,
|
||||
NotificationReasonCode reasonCode,
|
||||
Instant parkedAt) {
|
||||
|
||||
public ParkRequest {
|
||||
Objects.requireNonNull(routeId, "notification route ID must be non-null");
|
||||
if (policyRevision < 1 || expectedGeneration < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"policy revision must be positive and expected generation non-negative");
|
||||
}
|
||||
Objects.requireNonNull(faultScope, "notification fault scope must be non-null");
|
||||
if (faultScope == NotificationFaultScope.DELIVERY) {
|
||||
throw new IllegalArgumentException("shared admission gate cannot use DELIVERY fault scope");
|
||||
}
|
||||
scopeReference =
|
||||
NotificationIntentId.requireOpaque("admission scope reference", scopeReference);
|
||||
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
|
||||
Objects.requireNonNull(parkedAt, "admission parked time must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
enum ParkResult {
|
||||
NOT_REQUESTED,
|
||||
PARKED,
|
||||
ALREADY_PARKED,
|
||||
STALE_GENERATION
|
||||
}
|
||||
|
||||
record ResumeRequest(
|
||||
String operationToken,
|
||||
NotificationRouteId routeId,
|
||||
int policyRevision,
|
||||
NotificationFaultScope faultScope,
|
||||
String scopeReference,
|
||||
long expectedGeneration,
|
||||
int maximumParkedLegs,
|
||||
String actorReference,
|
||||
NotificationReasonCode reasonCode) {
|
||||
|
||||
public ResumeRequest {
|
||||
operationToken =
|
||||
NotificationIntentId.requireOpaque("admission resume operation token", operationToken);
|
||||
Objects.requireNonNull(routeId, "notification route ID must be non-null");
|
||||
if (policyRevision < 1 || expectedGeneration < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"policy revision must be positive and expected generation non-negative");
|
||||
}
|
||||
if (maximumParkedLegs < 1 || maximumParkedLegs > 100) {
|
||||
throw new IllegalArgumentException("maximum parked legs must be in 1..100");
|
||||
}
|
||||
Objects.requireNonNull(faultScope, "notification fault scope must be non-null");
|
||||
if (faultScope == NotificationFaultScope.DELIVERY) {
|
||||
throw new IllegalArgumentException("shared admission gate cannot use DELIVERY fault scope");
|
||||
}
|
||||
scopeReference =
|
||||
NotificationIntentId.requireOpaque("admission scope reference", scopeReference);
|
||||
actorReference =
|
||||
NotificationIntentId.requireOpaque("admission resume actor reference", actorReference);
|
||||
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record ReadinessProbe(boolean ready, NotificationReasonCode reasonCode) {
|
||||
|
||||
public ReadinessProbe {
|
||||
Objects.requireNonNull(reasonCode, "notification readiness reason must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
enum ResumeStatus {
|
||||
RESUMED,
|
||||
ALREADY_ACTIVE,
|
||||
STALE_GENERATION
|
||||
}
|
||||
|
||||
/**
|
||||
* Audited bounded result of rechecking every selected parked leg inside the gate-resume
|
||||
* transaction. Initial R1 never activates fallback while resuming a binding park.
|
||||
*/
|
||||
record ResumeResult(
|
||||
ResumeStatus status,
|
||||
long resultingGeneration,
|
||||
int queuedCount,
|
||||
int expiredCount,
|
||||
int cancelledCount,
|
||||
int technicallySuppressedCount,
|
||||
int policyRejectedCount,
|
||||
int activatedFallbackCount) {
|
||||
|
||||
public ResumeResult {
|
||||
Objects.requireNonNull(status, "notification admission resume status must be non-null");
|
||||
if (resultingGeneration < 0
|
||||
|| queuedCount < 0
|
||||
|| expiredCount < 0
|
||||
|| cancelledCount < 0
|
||||
|| technicallySuppressedCount < 0
|
||||
|| policyRejectedCount < 0
|
||||
|| activatedFallbackCount < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"notification admission resume generation/counts must be non-negative");
|
||||
}
|
||||
int processedLegCount =
|
||||
queuedCount
|
||||
+ expiredCount
|
||||
+ cancelledCount
|
||||
+ technicallySuppressedCount
|
||||
+ policyRejectedCount;
|
||||
if (processedLegCount > 100) {
|
||||
throw new IllegalArgumentException(
|
||||
"notification admission resume leg count must be bounded by 100");
|
||||
}
|
||||
if (activatedFallbackCount != 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"binding-park resume must not activate initial fallback legs");
|
||||
}
|
||||
if (status != ResumeStatus.RESUMED && processedLegCount != 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"non-mutating admission resume status cannot report processed legs");
|
||||
}
|
||||
}
|
||||
|
||||
public int processedLegCount() {
|
||||
return queuedCount
|
||||
+ expiredCount
|
||||
+ cancelledCount
|
||||
+ technicallySuppressedCount
|
||||
+ policyRejectedCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Durable append result; neither appended nor duplicate means provider delivery succeeded. */
|
||||
public sealed interface NotificationAppendResult
|
||||
permits NotificationAppendResult.Appended,
|
||||
NotificationAppendResult.DuplicateExisting,
|
||||
NotificationAppendResult.Rejected {
|
||||
|
||||
record Appended(NotificationIntentId intentId) implements NotificationAppendResult {
|
||||
|
||||
public Appended {
|
||||
Objects.requireNonNull(intentId, "notification intent ID must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record DuplicateExisting(NotificationIntentId intentId) implements NotificationAppendResult {
|
||||
|
||||
public DuplicateExisting {
|
||||
Objects.requireNonNull(intentId, "notification intent ID must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record Rejected(NotificationReasonCode reasonCode) implements NotificationAppendResult {
|
||||
|
||||
public Rejected {
|
||||
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Framework- and provider-neutral application failure carrying only a stable reason code. */
|
||||
public final class NotificationApplicationException extends RuntimeException {
|
||||
|
||||
private final NotificationReasonCode reasonCode;
|
||||
|
||||
public NotificationApplicationException(NotificationReasonCode reasonCode, Throwable cause) {
|
||||
super(
|
||||
Objects.requireNonNull(reasonCode, "notification reason code must be non-null").value(),
|
||||
cause);
|
||||
this.reasonCode = reasonCode;
|
||||
}
|
||||
|
||||
public NotificationReasonCode reasonCode() {
|
||||
return reasonCode;
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
/** Opaque identity of one authorized physical provider attempt. */
|
||||
public record NotificationAttemptId(String value) {
|
||||
|
||||
public NotificationAttemptId {
|
||||
value = NotificationIntentId.requireOpaque("attemptId", value);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Internal application collaborator asserting canonical ownership in an existing write boundary.
|
||||
*/
|
||||
public final class NotificationCanonicalWriterFenceGuard {
|
||||
|
||||
private final NotificationCanonicalWriterFencePort fence;
|
||||
private final NotificationCanonicalWriterRouteSet routes;
|
||||
|
||||
public NotificationCanonicalWriterFenceGuard(
|
||||
NotificationCanonicalWriterFencePort fence, NotificationCanonicalWriterRouteSet routes) {
|
||||
this.fence = Objects.requireNonNull(fence, "canonical writer fence port must be non-null");
|
||||
this.routes = Objects.requireNonNull(routes, "canonical writer route set must be non-null");
|
||||
}
|
||||
|
||||
public void assertCanonical(
|
||||
NotificationCanonicalWriterRouteSet.RouteRevision route, long expectedGeneration) {
|
||||
if (!routes.contains(route)) {
|
||||
throw new IllegalArgumentException(
|
||||
"route is outside canonical notification writer route set");
|
||||
}
|
||||
NotificationCanonicalWriterFencePort.FenceSnapshot snapshot =
|
||||
Objects.requireNonNull(
|
||||
fence.assertCanonicalInCallerTransaction(
|
||||
new NotificationCanonicalWriterFencePort.FenceRequest(route, expectedGeneration)),
|
||||
"canonical writer fence snapshot must be non-null");
|
||||
if (!snapshot.route().equals(route)) {
|
||||
throw failure("CANONICAL_WRITER_ROUTE_MISMATCH");
|
||||
}
|
||||
if (snapshot.owner() != NotificationWriterOwnership.CANONICAL) {
|
||||
throw failure("CANONICAL_WRITER_NOT_OWNER");
|
||||
}
|
||||
if (snapshot.generation() != expectedGeneration) {
|
||||
throw failure("STALE_CANONICAL_WRITER_GENERATION");
|
||||
}
|
||||
}
|
||||
|
||||
private static NotificationApplicationException failure(String reason) {
|
||||
return new NotificationApplicationException(new NotificationReasonCode(reason), null);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Acquires a transaction-scoped shared fence assertion. The persistence implementation must hold
|
||||
* the share lock until the caller's physical commit or rollback.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface NotificationCanonicalWriterFencePort {
|
||||
|
||||
FenceSnapshot assertCanonicalInCallerTransaction(FenceRequest request);
|
||||
|
||||
record FenceRequest(
|
||||
NotificationCanonicalWriterRouteSet.RouteRevision route, long expectedGeneration) {
|
||||
|
||||
public FenceRequest {
|
||||
Objects.requireNonNull(route, "notification writer route must be non-null");
|
||||
if (expectedGeneration < 0) {
|
||||
throw new IllegalArgumentException("expected writer generation must be non-negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record FenceSnapshot(
|
||||
NotificationCanonicalWriterRouteSet.RouteRevision route,
|
||||
NotificationWriterOwnership owner,
|
||||
long generation) {
|
||||
|
||||
public FenceSnapshot {
|
||||
Objects.requireNonNull(route, "notification writer route must be non-null");
|
||||
Objects.requireNonNull(owner, "notification writer owner must be non-null");
|
||||
if (generation < 0) {
|
||||
throw new IllegalArgumentException("writer generation must be non-negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Bounded, ordered canonical route-revision set that production admission is allowed to use. */
|
||||
public record NotificationCanonicalWriterRouteSet(List<RouteRevision> routes) {
|
||||
|
||||
public NotificationCanonicalWriterRouteSet {
|
||||
Objects.requireNonNull(routes, "canonical notification writer routes must be non-null");
|
||||
routes =
|
||||
routes.stream()
|
||||
.map(route -> Objects.requireNonNull(route, "canonical route must be non-null"))
|
||||
.sorted(
|
||||
Comparator.comparing((RouteRevision route) -> route.routeId().value())
|
||||
.thenComparingInt(RouteRevision::routeRevision))
|
||||
.toList();
|
||||
if (routes.isEmpty() || routes.size() > 100) {
|
||||
throw new IllegalArgumentException("canonical writer route set must contain 1..100 routes");
|
||||
}
|
||||
if (new HashSet<>(routes).size() != routes.size()) {
|
||||
throw new IllegalArgumentException("canonical writer route set contains a duplicate route");
|
||||
}
|
||||
long distinctRouteKeys = routes.stream().map(RouteRevision::routeId).distinct().count();
|
||||
if (distinctRouteKeys != routes.size()) {
|
||||
throw new IllegalArgumentException(
|
||||
"canonical writer route set contains multiple revisions for one route key");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean contains(RouteRevision route) {
|
||||
return routes.contains(route);
|
||||
}
|
||||
|
||||
public String digest() {
|
||||
MessageDigest digest = sha256();
|
||||
routes.forEach(
|
||||
route -> {
|
||||
update(digest, route.routeId().value());
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(route.routeRevision()).array());
|
||||
digest.update(
|
||||
ByteBuffer.allocate(Long.BYTES).putLong(route.predecessorGeneration()).array());
|
||||
});
|
||||
return java.util.HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException unavailable) {
|
||||
throw new IllegalStateException(
|
||||
"SHA-256 must be available on every Java runtime", unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
static void update(MessageDigest digest, String value) {
|
||||
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(encoded.length).array());
|
||||
digest.update(encoded);
|
||||
}
|
||||
|
||||
public record RouteRevision(
|
||||
NotificationRouteId routeId, int routeRevision, long predecessorGeneration) {
|
||||
|
||||
public RouteRevision {
|
||||
Objects.requireNonNull(routeId, "notification route ID must be non-null");
|
||||
if (routeRevision < 1 || routeRevision > 1_000_000 || predecessorGeneration < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"route revision must be in 1..1000000 and predecessor generation non-negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Pure validator over application-owned policy and provider/store/ingress capability facts. */
|
||||
public final class NotificationCapabilityCompatibilityValidator {
|
||||
|
||||
public Compatibility validate(
|
||||
NotificationKindPolicy policy,
|
||||
NotificationProviderCapabilityDescriptor provider,
|
||||
NotificationStoreCapabilityDescriptor store,
|
||||
Optional<NotificationReceiptIngressCapabilityDescriptor> receiptIngress,
|
||||
boolean receiptRequired) {
|
||||
Objects.requireNonNull(policy, "notification kind policy must be non-null");
|
||||
Objects.requireNonNull(provider, "notification provider descriptor must be non-null");
|
||||
Objects.requireNonNull(store, "notification store descriptor must be non-null");
|
||||
Objects.requireNonNull(receiptIngress, "receipt ingress container must be non-null");
|
||||
|
||||
List<NotificationReasonCode> reasons = new ArrayList<>();
|
||||
addIf(reasons, provider.channel() != policy.channel(), "PROVIDER_CHANNEL_MISMATCH");
|
||||
addIf(reasons, !provider.supportedModes().contains(policy.mode()), "PROVIDER_MODE_UNSUPPORTED");
|
||||
addIf(reasons, !provider.hiddenRetriesControlled(), "PROVIDER_HIDDEN_RETRY_UNCONTROLLED");
|
||||
addIf(
|
||||
reasons,
|
||||
provider.maximumTargets() < policy.maxTargetsPerRecipient(),
|
||||
"PROVIDER_TARGET_BOUND_INSUFFICIENT");
|
||||
addIf(
|
||||
reasons,
|
||||
policy.maxReconcileCalls() > 0 && !provider.reconciliationSupported(),
|
||||
"PROVIDER_RECONCILIATION_UNSUPPORTED");
|
||||
if (policy.mode() == NotificationMode.DURABLE_ASYNC) {
|
||||
addIf(
|
||||
reasons,
|
||||
!store.durableIntentStore() || !store.attemptJournal(),
|
||||
"DURABLE_STORE_UNAVAILABLE");
|
||||
}
|
||||
addIf(
|
||||
reasons,
|
||||
!store.availablePolicyRevisions().contains(policy.policyRevision()),
|
||||
"POLICY_REVISION_UNAVAILABLE");
|
||||
addIf(
|
||||
reasons,
|
||||
!store.availableTemplateRevisions().contains(policy.templateRef()),
|
||||
"TEMPLATE_REVISION_UNAVAILABLE");
|
||||
if (receiptRequired) {
|
||||
addIf(reasons, !provider.receiptSupported(), "PROVIDER_RECEIPT_UNSUPPORTED");
|
||||
addIf(reasons, !store.receiptInbox(), "RECEIPT_STORE_UNAVAILABLE");
|
||||
boolean ingressUnavailable =
|
||||
receiptIngress.isEmpty()
|
||||
|| !receiptIngress.orElseThrow().enabled()
|
||||
|| !receiptIngress.orElseThrow().authenticated()
|
||||
|| receiptIngress.orElseThrow().channel() != policy.channel()
|
||||
|| receiptIngress.orElseThrow().supportedFactTypes().isEmpty();
|
||||
addIf(reasons, ingressUnavailable, "RECEIPT_INGRESS_UNAVAILABLE");
|
||||
}
|
||||
return new Compatibility(reasons.isEmpty(), reasons);
|
||||
}
|
||||
|
||||
public void requireCompatible(
|
||||
NotificationKindPolicy policy,
|
||||
NotificationProviderCapabilityDescriptor provider,
|
||||
NotificationStoreCapabilityDescriptor store,
|
||||
Optional<NotificationReceiptIngressCapabilityDescriptor> receiptIngress,
|
||||
boolean receiptRequired) {
|
||||
Compatibility compatibility =
|
||||
validate(policy, provider, store, receiptIngress, receiptRequired);
|
||||
if (!compatibility.compatible()) {
|
||||
throw new NotificationApplicationException(
|
||||
new NotificationReasonCode("NOTIFICATION_CAPABILITY_INCOMPATIBLE"), null);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addIf(
|
||||
List<NotificationReasonCode> reasons, boolean condition, String reasonCode) {
|
||||
if (condition) {
|
||||
reasons.add(new NotificationReasonCode(reasonCode));
|
||||
}
|
||||
}
|
||||
|
||||
public record Compatibility(boolean compatible, List<NotificationReasonCode> reasonCodes) {
|
||||
|
||||
public Compatibility {
|
||||
reasonCodes =
|
||||
List.copyOf(
|
||||
Objects.requireNonNull(
|
||||
reasonCodes, "notification compatibility reasons must be non-null"));
|
||||
if (compatible != reasonCodes.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"compatible flag must equal an empty incompatibility reason set");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
/** Provider-neutral delivery medium. */
|
||||
public enum NotificationChannel {
|
||||
EMAIL,
|
||||
SLACK
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
/** Opaque identity of one provider leg for a logical recipient. */
|
||||
public record NotificationDeliveryId(String value) {
|
||||
|
||||
public NotificationDeliveryId {
|
||||
value = NotificationIntentId.requireOpaque("deliveryId", value);
|
||||
}
|
||||
}
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Durable delivery-leg state port; provider I/O is deliberately absent from this contract. */
|
||||
public interface NotificationDeliveryStorePort {
|
||||
|
||||
List<ClaimedDelivery> claimEligible(int maximumClaims, Instant now);
|
||||
|
||||
AttemptAuthorization reserveAndAuthorize(ClaimedDelivery claimed, Instant now);
|
||||
|
||||
FinalizationResult finalizeAttempt(
|
||||
AuthorizedAttempt attempt, AttemptFinalization finalization, Instant now);
|
||||
|
||||
List<ReconciliationClaim> claimForReconciliation(int maximumClaims, Instant now);
|
||||
|
||||
ReconciliationFinalizationResult finalizeReconciliation(
|
||||
ReconciliationClaim claim,
|
||||
NotificationReconciliationPort.ReconciliationOutcome outcome,
|
||||
Instant now);
|
||||
|
||||
int attachOrphanReceipts(int maximumAttachments, Instant now);
|
||||
|
||||
record ClaimedDelivery(
|
||||
NotificationDeliveryId deliveryId,
|
||||
NotificationFrozenPlan plan,
|
||||
int targetOrdinal,
|
||||
String claimToken,
|
||||
long rowVersion,
|
||||
long admissionGeneration) {
|
||||
|
||||
public ClaimedDelivery {
|
||||
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
|
||||
Objects.requireNonNull(plan, "notification frozen plan must be non-null");
|
||||
if (targetOrdinal < 0 || targetOrdinal >= plan.policy().maxTargetsPerRecipient()) {
|
||||
throw new IllegalArgumentException("target ordinal is outside the frozen plan bound");
|
||||
}
|
||||
claimToken = NotificationIntentId.requireOpaque("claim token", claimToken);
|
||||
if (rowVersion < 0 || admissionGeneration < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"row version and admission generation must be non-negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface AttemptAuthorization permits Authorized, StaleClaim, NotEligible {}
|
||||
|
||||
record Authorized(AuthorizedAttempt attempt) implements AttemptAuthorization {
|
||||
|
||||
public Authorized {
|
||||
Objects.requireNonNull(attempt, "authorized notification attempt must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record StaleClaim(NotificationDeliveryId deliveryId, NotificationReasonCode reasonCode)
|
||||
implements AttemptAuthorization {
|
||||
|
||||
public StaleClaim {
|
||||
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
|
||||
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record NotEligible(NotificationDeliveryId deliveryId, NotificationReasonCode reasonCode)
|
||||
implements AttemptAuthorization {
|
||||
|
||||
public NotEligible {
|
||||
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
|
||||
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record AuthorizedAttempt(
|
||||
NotificationDeliveryId deliveryId,
|
||||
NotificationAttemptId attemptId,
|
||||
NotificationFrozenPlan plan,
|
||||
int targetOrdinal,
|
||||
String claimToken,
|
||||
String executionToken,
|
||||
long expectedRowVersion,
|
||||
long admissionGeneration,
|
||||
String admissionScopeReference,
|
||||
Instant absoluteDeadline) {
|
||||
|
||||
public AuthorizedAttempt {
|
||||
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
|
||||
Objects.requireNonNull(attemptId, "notification attempt ID must be non-null");
|
||||
Objects.requireNonNull(plan, "notification frozen plan must be non-null");
|
||||
if (targetOrdinal < 0 || targetOrdinal >= plan.policy().maxTargetsPerRecipient()) {
|
||||
throw new IllegalArgumentException("target ordinal is outside the frozen plan bound");
|
||||
}
|
||||
claimToken = NotificationIntentId.requireOpaque("claim token", claimToken);
|
||||
executionToken =
|
||||
NotificationIntentId.requireOpaque("attempt execution token", executionToken);
|
||||
if (claimToken.equals(executionToken)) {
|
||||
throw new IllegalArgumentException(
|
||||
"attempt execution token must be distinct from the claim token");
|
||||
}
|
||||
if (expectedRowVersion < 0 || admissionGeneration < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"row version and admission generation must be non-negative");
|
||||
}
|
||||
admissionScopeReference =
|
||||
NotificationIntentId.requireOpaque("admission scope reference", admissionScopeReference);
|
||||
Objects.requireNonNull(absoluteDeadline, "absolute attempt deadline must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "AuthorizedAttempt[deliveryId=<redacted>, attemptId="
|
||||
+ attemptId
|
||||
+ ", plan=<redacted>, targetOrdinal="
|
||||
+ targetOrdinal
|
||||
+ ", claimToken=<redacted>, executionToken=<redacted>, expectedRowVersion="
|
||||
+ expectedRowVersion
|
||||
+ ", admissionGeneration="
|
||||
+ admissionGeneration
|
||||
+ ", admissionScopeReference=<redacted>, absoluteDeadline="
|
||||
+ absoluteDeadline
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
|
||||
record AttemptFinalization(
|
||||
ProviderAttemptOutcome providerOutcome,
|
||||
TerminalState terminalState,
|
||||
boolean fallbackEligible,
|
||||
NotificationAdmissionReadinessPort.ParkResult parkResult) {
|
||||
|
||||
public AttemptFinalization {
|
||||
Objects.requireNonNull(providerOutcome, "provider attempt outcome must be non-null");
|
||||
Objects.requireNonNull(terminalState, "notification terminal state must be non-null");
|
||||
Objects.requireNonNull(parkResult, "admission park result must be non-null");
|
||||
if (fallbackEligible
|
||||
&& providerOutcome.submissionCertainty() != SubmissionCertainty.DEFINITELY_NOT_APPLIED) {
|
||||
throw new IllegalArgumentException("fallback is eligible only for DEFINITELY_NOT_APPLIED");
|
||||
}
|
||||
if (terminalState == TerminalState.TERMINAL_INDETERMINATE
|
||||
&& providerOutcome.submissionCertainty() != SubmissionCertainty.INDETERMINATE) {
|
||||
throw new IllegalArgumentException(
|
||||
"TERMINAL_INDETERMINATE requires an indeterminate provider outcome");
|
||||
}
|
||||
if (terminalState == TerminalState.PARKED_BINDING
|
||||
&& providerOutcome.retryDisposition() != RetryDisposition.PARK_BINDING) {
|
||||
throw new IllegalArgumentException("PARKED_BINDING requires PARK_BINDING disposition");
|
||||
}
|
||||
if (providerOutcome.retryDisposition() == RetryDisposition.PARK_BINDING) {
|
||||
if (parkResult == NotificationAdmissionReadinessPort.ParkResult.NOT_REQUESTED) {
|
||||
throw new IllegalArgumentException("PARK_BINDING requires an admission park result");
|
||||
}
|
||||
boolean parked =
|
||||
parkResult == NotificationAdmissionReadinessPort.ParkResult.PARKED
|
||||
|| parkResult == NotificationAdmissionReadinessPort.ParkResult.ALREADY_PARKED;
|
||||
TerminalState expected =
|
||||
parked ? TerminalState.PARKED_BINDING : TerminalState.RETRY_SCHEDULED;
|
||||
if (terminalState != expected) {
|
||||
throw new IllegalArgumentException(
|
||||
"terminal state must reflect the generation-guarded admission park result");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TerminalState {
|
||||
ACCEPTED,
|
||||
RETRY_SCHEDULED,
|
||||
PARKED_BINDING,
|
||||
TERMINAL_FAILURE,
|
||||
TERMINAL_INDETERMINATE
|
||||
}
|
||||
|
||||
enum FinalizationResult {
|
||||
APPLIED,
|
||||
LATE_EXACT_APPLIED,
|
||||
STALE_EXECUTION_TOKEN,
|
||||
ALREADY_TERMINAL
|
||||
}
|
||||
|
||||
record ReconciliationClaim(
|
||||
NotificationDeliveryId deliveryId,
|
||||
String executionToken,
|
||||
long expectedRowVersion,
|
||||
NotificationRouteId routeId,
|
||||
int routeRevision,
|
||||
String bindingDigest,
|
||||
int targetOrdinal,
|
||||
String targetReference,
|
||||
String providerCapabilityReference,
|
||||
String providerBindingRevision,
|
||||
String credentialGeneration,
|
||||
String lookupReference,
|
||||
ReconciliationLookupKind lookupKind,
|
||||
Instant absoluteDeadline) {
|
||||
|
||||
public ReconciliationClaim {
|
||||
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
|
||||
executionToken =
|
||||
NotificationIntentId.requireOpaque("reconciliation execution token", executionToken);
|
||||
if (expectedRowVersion < 0 || routeRevision < 1 || targetOrdinal < 0 || targetOrdinal > 15) {
|
||||
throw new IllegalArgumentException(
|
||||
"reconciliation row version, route revision and target ordinal are invalid");
|
||||
}
|
||||
Objects.requireNonNull(routeId, "reconciliation route ID must be non-null");
|
||||
if (bindingDigest == null || !bindingDigest.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException(
|
||||
"reconciliation binding digest must be a lowercase SHA-256 digest");
|
||||
}
|
||||
targetReference =
|
||||
NotificationIntentId.requireOpaque("reconciliation target reference", targetReference);
|
||||
providerCapabilityReference =
|
||||
NotificationIntentId.requireOpaque(
|
||||
"reconciliation provider capability reference", providerCapabilityReference);
|
||||
providerBindingRevision =
|
||||
NotificationIntentId.requireOpaque(
|
||||
"reconciliation provider binding revision", providerBindingRevision);
|
||||
credentialGeneration =
|
||||
NotificationIntentId.requireSlug(
|
||||
"reconciliation credential generation", credentialGeneration);
|
||||
lookupReference =
|
||||
NotificationIntentId.requireOpaque(
|
||||
"provider reconciliation lookup reference", lookupReference);
|
||||
Objects.requireNonNull(lookupKind, "provider reconciliation lookup kind must be non-null");
|
||||
Objects.requireNonNull(absoluteDeadline, "reconciliation deadline must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ReconciliationClaim[deliveryId=<redacted>, executionToken=<redacted>, "
|
||||
+ "expectedRowVersion="
|
||||
+ expectedRowVersion
|
||||
+ ", routeId="
|
||||
+ routeId
|
||||
+ ", routeRevision="
|
||||
+ routeRevision
|
||||
+ ", bindingDigest="
|
||||
+ bindingDigest
|
||||
+ ", targetOrdinal="
|
||||
+ targetOrdinal
|
||||
+ ", targetReference=<redacted>, providerCapabilityReference="
|
||||
+ providerCapabilityReference
|
||||
+ ", providerBindingRevision="
|
||||
+ providerBindingRevision
|
||||
+ ", credentialGeneration="
|
||||
+ credentialGeneration
|
||||
+ ", lookupReference=<redacted>, lookupKind="
|
||||
+ lookupKind
|
||||
+ ", absoluteDeadline="
|
||||
+ absoluteDeadline
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
|
||||
enum ReconciliationLookupKind {
|
||||
PRE_SEND_CORRELATION,
|
||||
CLIENT_OPERATION_KEY,
|
||||
MESSAGE_REFERENCE
|
||||
}
|
||||
|
||||
enum ReconciliationFinalizationResult {
|
||||
APPLIED,
|
||||
LATE_EXACT_APPLIED,
|
||||
STALE_EXECUTION_TOKEN,
|
||||
ALREADY_TERMINAL
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import dev.caskeleton.application.command.Command;
|
||||
|
||||
/** Requests one bounded cross-tenant dispatch cycle. */
|
||||
public record NotificationDispatchCommand(int maximumClaims) implements Command {
|
||||
|
||||
public NotificationDispatchCommand {
|
||||
if (maximumClaims < 1 || maximumClaims > 100) {
|
||||
throw new IllegalArgumentException("maximum notification claims must be in 1..100");
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
/** Bounded non-sensitive aggregate outcome of one dispatch cycle. */
|
||||
public record NotificationDispatchResult(
|
||||
int claimedCount,
|
||||
int authorizedCount,
|
||||
int providerCallCount,
|
||||
int finalizedCount,
|
||||
int staleClaimCount,
|
||||
int indeterminateCount,
|
||||
int parkedCount) {
|
||||
|
||||
public NotificationDispatchResult {
|
||||
int[] counts = {
|
||||
claimedCount,
|
||||
authorizedCount,
|
||||
providerCallCount,
|
||||
finalizedCount,
|
||||
staleClaimCount,
|
||||
indeterminateCount,
|
||||
parkedCount
|
||||
};
|
||||
for (int count : counts) {
|
||||
if (count < 0 || count > 100) {
|
||||
throw new IllegalArgumentException("notification dispatch counts must be in 0..100");
|
||||
}
|
||||
}
|
||||
if (authorizedCount > claimedCount
|
||||
|| providerCallCount > authorizedCount
|
||||
|| finalizedCount > providerCallCount
|
||||
|| staleClaimCount > claimedCount
|
||||
|| indeterminateCount > providerCallCount
|
||||
|| parkedCount > providerCallCount) {
|
||||
throw new IllegalArgumentException("notification dispatch counts are inconsistent");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user