merge: integrate notification production capability
# Conflicts: # docs/superpowers/plans/2026-07-28-notification-production-capability.md # src/adapter/outbound/persistence-jpa/build.gradle # src/adapter/outbound/persistence-jpa/gradle.lockfile # src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java # src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java # src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidator.java # src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDeliveryStorePort.java # src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchUseCase.java # src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFrozenPlan.java # src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceCommand.java # src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceResult.java # src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceStorePort.java # src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidatorTest.java # src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationDispatchUseCaseTest.java # src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCaseTest.java # src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPlanningBoundaryTest.java # src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationValueContractTest.java # src/application-core/src/test/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCaseTest.java
This commit is contained in:
@@ -7,7 +7,13 @@
|
|||||||
> RED/GREEN/verification 규칙을 수동으로 명시한다.
|
> RED/GREEN/verification 규칙을 수동으로 명시한다.
|
||||||
|
|
||||||
- 작성일: 2026-07-28
|
- 작성일: 2026-07-28
|
||||||
- 상태: proposed implementation plan, 구현 미착수
|
- 상태: Wave A/B 및 Task 8 구현 checkpoint를 main 통합. Task 9 이후는 미착수
|
||||||
|
- 원 작업 위치: `codex/notification-production-capability` 격리 worktree
|
||||||
|
- 통합 위치: `/home/donghyeon/workspace/clean-architecture-backend-template` (`main`)
|
||||||
|
- 현재 checkpoint: Wave A application/transaction foundation, Wave B catalog/render/provider
|
||||||
|
fake protocol, Task 8 local crypto primitive 구현. Task 9의 migration/persistence model과 이후
|
||||||
|
provider/runtime 범위는 미착수다. 아래 isolated-worktree 검증 기록과 main 통합 검증 결과는
|
||||||
|
서로 다른 증거로 구분한다.
|
||||||
- 설계 정본:
|
- 설계 정본:
|
||||||
[Notification Production Capability Deep Design](../specs/2026-07-28-notification-production-capability-design.md)
|
[Notification Production Capability Deep Design](../specs/2026-07-28-notification-production-capability-design.md)
|
||||||
- 비교한 실행 계획:
|
- 비교한 실행 계획:
|
||||||
@@ -783,7 +789,7 @@ removing bindings, but these types must remain while compiled consumers exist.
|
|||||||
|
|
||||||
### Wave A exit gate
|
### Wave A exit gate
|
||||||
|
|
||||||
- [ ] Run:
|
- [x] Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src && ./gradlew :application-core:check \
|
cd src && ./gradlew :application-core:check \
|
||||||
@@ -795,14 +801,11 @@ removing bindings, but these types must remain while compiled consumers exist.
|
|||||||
--tests '*CleanArchitectureTest' --console=plain
|
--tests '*CleanArchitectureTest' --console=plain
|
||||||
```
|
```
|
||||||
|
|
||||||
Current evidence: the first command passes. The wildcard architecture command executes
|
Isolated-worktree evidence: both commands pass, including the wildcard architecture test.
|
||||||
`CleanArchitectureTest` successfully (59/59) but remains blocked by the unrelated
|
|
||||||
`DisabledAdapterArchitectureTest` Redis optional-bean gating violation. The exact
|
|
||||||
`dev.caskeleton.bootstrap.architecture.CleanArchitectureTest` command passes.
|
|
||||||
|
|
||||||
- [x] Confirm application bytecode/import scan contains no Spring, JPA, Slack, AWS, JSON or HTTP
|
- [x] Confirm application bytecode/import scan contains no Spring, JPA, Slack, AWS, JSON or HTTP
|
||||||
provider type.
|
provider type.
|
||||||
- [ ] Request an application/transaction boundary review before Wave B.
|
- [x] Request an application/transaction boundary review before Wave B.
|
||||||
- [x] Update the LLM Wiki branch-note with Wave A evidence and an explicit derived-document decision.
|
- [x] Update the LLM Wiki branch-note with Wave A evidence and an explicit derived-document decision.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -832,9 +835,10 @@ removing bindings, but these types must remain while compiled consumers exist.
|
|||||||
- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCanonicalRouteCatalogTest.java`
|
- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCanonicalRouteCatalogTest.java`
|
||||||
- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCutoverRouteCatalogTest.java`
|
- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCutoverRouteCatalogTest.java`
|
||||||
|
|
||||||
- [ ] RED cases: unknown/blank/duplicate local catalog entry; channel/provider mismatch;
|
- [x] RED cases: unknown/blank/duplicate local catalog entry; channel/provider mismatch;
|
||||||
durable + legacy/fail-open; receipt-required + unsupported provider; unsafe fallback after
|
durable + legacy/fail-open; receipt-required + unsupported provider; unsafe fallback after
|
||||||
indeterminate; target/retry/reconcile/amplification bound; cyclic binding.
|
indeterminate; target/retry/reconcile/amplification bound; unsupported non-`SINGLE` binding.
|
||||||
|
Initial R1 intentionally compiles only the three reviewed `SINGLE` cards.
|
||||||
- [ ] RED cutover-catalog cases:
|
- [ ] RED cutover-catalog cases:
|
||||||
`NotificationCanonicalRouteCatalog` is the retained key-only canonical SSOT and maps with the
|
`NotificationCanonicalRouteCatalog` is the retained key-only canonical SSOT and maps with the
|
||||||
trusted runtime target config to application-owned `NotificationCanonicalWriterRouteSet`.
|
trusted runtime target config to application-owned `NotificationCanonicalWriterRouteSet`.
|
||||||
@@ -858,7 +862,7 @@ removing bindings, but these types must remain while compiled consumers exist.
|
|||||||
A catalog route
|
A catalog route
|
||||||
with no live legacy consumer is initialized as closed LEGACY predecessor and switched through
|
with no live legacy consumer is initialized as closed LEGACY predecessor and switched through
|
||||||
the audited protocol; it is never directly seeded canonical in PRE.
|
the audited protocol; it is never directly seeded canonical in PRE.
|
||||||
- [ ] Verify RED:
|
- [x] Verify RED:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src && ./gradlew :adapter:outbound:notification:test \
|
cd src && ./gradlew :adapter:outbound:notification:test \
|
||||||
@@ -870,14 +874,14 @@ removing bindings, but these types must remain while compiled consumers exist.
|
|||||||
|
|
||||||
Expected failure: canonical descriptors/compiler do not exist.
|
Expected failure: canonical descriptors/compiler do not exist.
|
||||||
|
|
||||||
- [ ] Implement a pure, deterministic compiler over explicit input; do not inspect Spring beans,
|
- [x] Implement a pure, deterministic compiler over explicit input; do not inspect Spring beans,
|
||||||
application context, persistence schema or inbound adapters.
|
application context, persistence schema or inbound adapters.
|
||||||
- [ ] Keep `expected-state`, exact actual/expected binding IDs, application mode/admission matching,
|
- [x] Keep `expected-state`, exact actual/expected binding IDs, application mode/admission matching,
|
||||||
store capability and ingress topology out of this sibling-local compiler. Task 17 passes
|
store capability and ingress topology out of this sibling-local compiler. Task 17 passes
|
||||||
provider-neutral descriptors to the application compatibility validator for those checks.
|
provider-neutral descriptors to the application compatibility validator for those checks.
|
||||||
- [ ] Register only the three initial card IDs. Legacy descriptors must explicitly advertise R0,
|
- [x] Register only the three initial card IDs. Legacy descriptors must explicitly advertise R0,
|
||||||
no durable/receipt capability.
|
no durable/receipt capability.
|
||||||
- [ ] Emit a sorted immutable binding graph and manifest digest; unknown inputs fail closed.
|
- [x] Emit a sorted immutable binding graph and manifest digest; unknown inputs fail closed.
|
||||||
- [ ] Emit one immutable `NotificationCutoverRouteCatalog` and digest from the same route descriptor
|
- [ ] Emit one immutable `NotificationCutoverRouteCatalog` and digest from the same route descriptor
|
||||||
inputs over the retained immutable `NotificationCanonicalRouteCatalog`. Bootstrap converts the
|
inputs over the retained immutable `NotificationCanonicalRouteCatalog`. Bootstrap converts the
|
||||||
canonical catalog to `NotificationCanonicalWriterRouteSet` and the PRE decorator to
|
canonical catalog to `NotificationCanonicalWriterRouteSet` and the PRE decorator to
|
||||||
@@ -891,14 +895,16 @@ removing bindings, but these types must remain while compiled consumers exist.
|
|||||||
route has zero permits. `NotificationCutoverRouteCatalogTest` freezes the digest algorithm and
|
route has zero permits. `NotificationCutoverRouteCatalogTest` freezes the digest algorithm and
|
||||||
proves the exact registry value that batch initialization must persist; after initialization,
|
proves the exact registry value that batch initialization must persist; after initialization,
|
||||||
PRE composition rejects any persisted/catalog mismatch instead of silently refreshing it.
|
PRE composition rejects any persisted/catalog mismatch instead of silently refreshing it.
|
||||||
- [ ] Implement `NotificationPlanPort` by converting the selected adapter-local compiled binding into
|
Static catalog/digest/key-set validation is implemented; persisted-registry comparison and
|
||||||
|
bootstrap enforcement remain explicitly deferred to Task 17.
|
||||||
|
- [x] Implement `NotificationPlanPort` by converting the selected adapter-local compiled binding into
|
||||||
an application-owned `NotificationFrozenPlan`. The conversion freezes policy/route/template/
|
an application-owned `NotificationFrozenPlan`. The conversion freezes policy/route/template/
|
||||||
renderer/provider-leg revisions and contains no credential, SDK, settings or adapter type.
|
renderer/provider-leg revisions and contains no credential, SDK, settings or adapter type.
|
||||||
- [ ] Derive the application-owned provider capability descriptor from the actual compiled cards,
|
- [x] Derive the application-owned provider capability descriptor from the actual compiled cards,
|
||||||
renderer and client capabilities. Do not reconstruct “actual” provider facts from the expected
|
renderer and client capabilities. Do not reconstruct “actual” provider facts from the expected
|
||||||
bootstrap settings.
|
bootstrap settings.
|
||||||
- [ ] Verify GREEN with the same command.
|
- [x] Verify GREEN with the same command.
|
||||||
- [ ] Acceptance claim: local graph compatibility only, not actual composition/readiness.
|
- [x] Acceptance claim: local graph compatibility only, not actual composition/readiness.
|
||||||
|
|
||||||
**Rollback checkpoint:** compiler can coexist dark with the legacy router until Task 18 canonical
|
**Rollback checkpoint:** compiler can coexist dark with the legacy router until Task 18 canonical
|
||||||
composition succeeds.
|
composition succeeds.
|
||||||
@@ -923,24 +929,24 @@ composition succeeds.
|
|||||||
- `src/adapter/outbound/notification/src/test/resources/notification/templates/email/contract-v1.html`
|
- `src/adapter/outbound/notification/src/test/resources/notification/templates/email/contract-v1.html`
|
||||||
- `src/adapter/outbound/notification/src/test/resources/notification/templates/slack/contract-v1.txt`
|
- `src/adapter/outbound/notification/src/test/resources/notification/templates/slack/contract-v1.txt`
|
||||||
|
|
||||||
- [ ] RED cases: checksum/revision drift; missing/unknown/unused parameter; exact locale fallback
|
- [x] RED cases: checksum/revision drift; missing/unknown/unused parameter; exact locale fallback
|
||||||
independent of JVM default; email header CR/LF; HTML text/attribute/URL escaping; Slack
|
independent of JVM default; email header CR/LF; HTML text/attribute/URL escaping; Slack
|
||||||
mrkdwn/plain-text/mention escaping; output byte/block/depth limits; no file/network/reflection
|
mrkdwn/plain-text/mention escaping; output byte/block/depth limits; no file/network/reflection
|
||||||
include; redacted failures.
|
include; redacted failures.
|
||||||
- [ ] Verify RED:
|
- [x] Verify RED:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src && ./gradlew :adapter:outbound:notification:test \
|
cd src && ./gradlew :adapter:outbound:notification:test \
|
||||||
--tests '*NotificationTemplateRendererTest' --console=plain
|
--tests '*NotificationTemplateRendererTest' --console=plain
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] Implement checked-in resource loading by exact manifest/checksum. Keep business-specific
|
- [x] Implement checked-in resource loading by exact manifest/checksum. Keep business-specific
|
||||||
assets out of production main resources until a consuming project supplies a reviewed catalog;
|
assets out of production main resources until a consuming project supplies a reviewed catalog;
|
||||||
use test resources only for the generic contract proof.
|
use test resources only for the generic contract proof.
|
||||||
- [ ] Produce local email subject/text/HTML and Slack Block Kit through typed builders; never accept
|
- [x] Produce local email subject/text/HTML and Slack Block Kit through typed builders; never accept
|
||||||
caller-supplied arbitrary JSON or provider block objects.
|
caller-supplied arbitrary JSON or provider block objects.
|
||||||
- [ ] Verify GREEN with the same command.
|
- [x] Verify GREEN with the same command.
|
||||||
- [ ] Acceptance claim: deterministic local render R1; no provider call.
|
- [x] Acceptance claim: deterministic local render R1; no provider call.
|
||||||
|
|
||||||
**Rollback checkpoint:** retained intent template revisions prevent later asset deletion. Before
|
**Rollback checkpoint:** retained intent template revisions prevent later asset deletion. Before
|
||||||
durable append, this task is independently reversible.
|
durable append, this task is independently reversible.
|
||||||
@@ -973,11 +979,11 @@ durable append, this task is independently reversible.
|
|||||||
- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationReconciliationAdapterTest.java`
|
- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationReconciliationAdapterTest.java`
|
||||||
- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAdmissionReadinessAdapterTest.java`
|
- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAdmissionReadinessAdapterTest.java`
|
||||||
|
|
||||||
- [ ] RED cases: `prepare` has no I/O; one authorization invokes client exactly once; deadline is
|
- [x] RED cases: `prepare` has no I/O; one authorization invokes client exactly once; deadline is
|
||||||
absolute/bounded; pre-wire validation maps to definitely-not-applied; possible write timeout
|
absolute/bounded; pre-wire validation maps to definitely-not-applied; possible write timeout
|
||||||
maps indeterminate; accepted response stores only opaque provider reference; SDK exceptions
|
maps indeterminate; accepted response stores only opaque provider reference; SDK exceptions
|
||||||
never escape to application.
|
never escape to application.
|
||||||
- [ ] Verify RED:
|
- [x] Verify RED:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src && ./gradlew :adapter:outbound:notification:test \
|
cd src && ./gradlew :adapter:outbound:notification:test \
|
||||||
@@ -989,40 +995,40 @@ durable append, this task is independently reversible.
|
|||||||
--console=plain
|
--console=plain
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] Keep this SPI adapter-internal. Implement the application
|
- [x] Keep this SPI adapter-internal. Implement the application
|
||||||
`NotificationProviderAttemptPort` with compiled binding + renderer + internal client lookup.
|
`NotificationProviderAttemptPort` with compiled binding + renderer + internal client lookup.
|
||||||
- [ ] Keep secret material resolution, control-plane readiness and provider-local rate admission
|
- [x] Keep secret material resolution, control-plane readiness and provider-local rate admission
|
||||||
behind adapter-owned interfaces. Profiles contain secret references/generations only; readiness
|
behind adapter-owned interfaces. Profiles contain secret references/generations only; readiness
|
||||||
snapshots contain bounded non-secret identity/capability facts.
|
snapshots contain bounded non-secret identity/capability facts.
|
||||||
- [ ] Secret acquisition returns a versioned `AutoCloseable` mutable byte/char handle. Acquire it per
|
- [x] Secret acquisition returns a versioned `AutoCloseable` mutable byte/char handle. Acquire it per
|
||||||
provider operation, close it on success/exception/cancellation, wipe on close, reject use after
|
provider operation, close it on success/exception/cancellation, wipe on close, reject use after
|
||||||
close, and redact `toString`/exceptions. Never store the raw token in an adapter-owned
|
close, and redact `toString`/exceptions. Never store the raw token in an adapter-owned
|
||||||
record/String/settings field; the wipe claim covers only the adapter-facing mutable copy.
|
record/String/settings field; the wipe claim covers only the adapter-facing mutable copy.
|
||||||
- [ ] Implement the application-owned `NotificationAdmissionReadinessPort` with the adapter-internal
|
- [x] Implement the application-owned `NotificationAdmissionReadinessPort` with the adapter-internal
|
||||||
readiness probes. Application admission use cases must never import the internal probe type.
|
readiness probes. Application admission use cases must never import the internal probe type.
|
||||||
- [ ] Freeze the outbound binding matrix:
|
- [x] Freeze the outbound binding matrix:
|
||||||
`NotificationPlanPort -> NotificationPlanAdapter`,
|
`NotificationPlanPort -> NotificationPlanAdapter`,
|
||||||
`InlineNotificationAttemptPort -> InlineNotificationAttemptAdapter`,
|
`InlineNotificationAttemptPort -> InlineNotificationAttemptAdapter`,
|
||||||
`NotificationProviderAttemptPort -> NotificationProviderAttemptAdapter`,
|
`NotificationProviderAttemptPort -> NotificationProviderAttemptAdapter`,
|
||||||
`NotificationReconciliationPort -> NotificationReconciliationAdapter`,
|
`NotificationReconciliationPort -> NotificationReconciliationAdapter`,
|
||||||
`NotificationAdmissionReadinessPort -> NotificationAdmissionReadinessAdapter`.
|
`NotificationAdmissionReadinessPort -> NotificationAdmissionReadinessAdapter`.
|
||||||
Every implementation has a focused contract test before composition.
|
Every implementation has a focused contract test before composition.
|
||||||
- [ ] Keep attempt correlation, optional provider operation key and post-response message reference
|
- [x] Keep attempt correlation, optional provider operation key and post-response message reference
|
||||||
as distinct types.
|
as distinct types.
|
||||||
- [ ] Verify GREEN with the same command and:
|
- [x] Verify GREEN with the same command and:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src && ./gradlew :adapter:outbound:notification:check --console=plain
|
cd src && ./gradlew :adapter:outbound:notification:check --console=plain
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] Acceptance claim: deterministic fake protocol R1, no exact provider card qualification.
|
- [x] Acceptance claim: deterministic fake protocol R1, no exact provider card qualification.
|
||||||
|
|
||||||
**Rollback checkpoint:** no network resources are created until a canonical profile is bound in
|
**Rollback checkpoint:** no network resources are created until a canonical profile is bound in
|
||||||
Task 18.
|
Task 18.
|
||||||
|
|
||||||
### Wave B exit gate
|
### Wave B exit gate
|
||||||
|
|
||||||
- [ ] Run:
|
- [x] Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src && ./gradlew :application-core:check \
|
cd src && ./gradlew :application-core:check \
|
||||||
@@ -1031,10 +1037,11 @@ Task 18.
|
|||||||
--console=plain
|
--console=plain
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] Verify the notification leaf has no project dependency on persistence, inbound-web or
|
- [x] Verify the notification leaf has no project dependency on persistence, inbound-web or
|
||||||
httpclient.
|
httpclient.
|
||||||
- [ ] Request catalog/template/provider-SPI review.
|
- [x] Request catalog/template/provider-SPI review.
|
||||||
- [ ] Update the LLM Wiki branch-note with Wave B evidence and an explicit derived-document decision.
|
Final independent re-review: Blocker 0 / High 0.
|
||||||
|
- [x] Update the LLM Wiki branch-note with Wave B evidence and an explicit derived-document decision.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+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}}
|
||||||
+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);
|
||||||
|
}
|
||||||
+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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -6,6 +6,7 @@ import com.tngtech.archunit.core.domain.JavaClasses;
|
|||||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||||
import com.tngtech.archunit.lang.EvaluationResult;
|
import com.tngtech.archunit.lang.EvaluationResult;
|
||||||
import dev.caskeleton.application.architecture.violations.ApplicationDiagnosticFrameworkViolation;
|
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.allowed.application.CleanProjectionQueryPort;
|
||||||
import dev.caskeleton.bootstrap.architecture.fixtures.application.RootWriteTransactionBoundaryUseCase;
|
import dev.caskeleton.bootstrap.architecture.fixtures.application.RootWriteTransactionBoundaryUseCase;
|
||||||
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture;
|
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture;
|
||||||
|
|||||||
+15
-3
@@ -1231,8 +1231,9 @@ class CleanArchitectureTest {
|
|||||||
static final ArchRule OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES =
|
static final ArchRule OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES =
|
||||||
methods()
|
methods()
|
||||||
.that()
|
.that()
|
||||||
.areDeclaredInClassesThat()
|
.areDeclaredInClassesThat(
|
||||||
.resideInAPackage("..adapter.outbound..")
|
JavaClass.Predicates.resideInAPackage("..adapter.outbound..")
|
||||||
|
.and(implementsApplicationLayerPort()))
|
||||||
// @Configuration factory classes are excluded: a @Bean method legitimately
|
// @Configuration factory classes are excluded: a @Bean method legitimately
|
||||||
// returns the adapter's own outbound port type for DI wiring (e.g.
|
// returns the adapter's own outbound port type for DI wiring (e.g.
|
||||||
// MessagingConfig#messagePublisher -> MessagePublisher) — that is
|
// MessagingConfig#messagePublisher -> MessagePublisher) — that is
|
||||||
@@ -1277,7 +1278,7 @@ class CleanArchitectureTest {
|
|||||||
"..adapter.inbound.web..",
|
"..adapter.inbound.web..",
|
||||||
"..adapter.outbound.persistence.."))
|
"..adapter.outbound.persistence.."))
|
||||||
.as(
|
.as(
|
||||||
"B7: externally reachable outbound adapter public methods must return domain types"
|
"B7: outbound application-port adapter public methods must return domain types"
|
||||||
+ " (or primitives/wrappers/Optional) — raw external response types must not"
|
+ " (or primitives/wrappers/Optional) — raw external response types must not"
|
||||||
+ " escape the adapter package (feature-boundary-validation-mapping-contract B7"
|
+ " escape the adapter package (feature-boundary-validation-mapping-contract B7"
|
||||||
+ " ACL). @Configuration @Bean factory methods and @ConfigurationProperties"
|
+ " ACL). @Configuration @Bean factory methods and @ConfigurationProperties"
|
||||||
@@ -1553,6 +1554,17 @@ class CleanArchitectureTest {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
@ArchTest
|
||||||
static final ArchRule VALID_CASCADE_DEPTH_AT_MOST_THREE =
|
static final ArchRule VALID_CASCADE_DEPTH_AT_MOST_THREE =
|
||||||
classes()
|
classes()
|
||||||
|
|||||||
+4
-1
@@ -1,11 +1,14 @@
|
|||||||
package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound;
|
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
|
* 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).
|
* 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() {
|
public RawExternalResponseFixture leakRaw() {
|
||||||
return new RawExternalResponseFixture();
|
return new RawExternalResponseFixture();
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -1,11 +1,13 @@
|
|||||||
package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.httpclient.activation;
|
package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.httpclient.activation;
|
||||||
|
|
||||||
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture;
|
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture;
|
||||||
|
import dev.caskeleton.bootstrap.architecture.violations.application.RawLeakPortFixture;
|
||||||
|
|
||||||
/** Proves that an activation package name cannot bypass the outbound raw-type leak guard. */
|
/** Proves that an activation package name cannot bypass the outbound raw-type leak guard. */
|
||||||
public class EvilActivationLeak {
|
public class EvilActivationLeak implements RawLeakPortFixture {
|
||||||
|
|
||||||
public RawExternalResponseFixture require() {
|
@Override
|
||||||
|
public RawExternalResponseFixture leakRaw() {
|
||||||
return new RawExternalResponseFixture();
|
return new RawExternalResponseFixture();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -1,11 +1,13 @@
|
|||||||
package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.settingsbypass;
|
package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.settingsbypass;
|
||||||
|
|
||||||
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture;
|
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture;
|
||||||
|
import dev.caskeleton.bootstrap.architecture.violations.application.RawLeakPortFixture;
|
||||||
|
|
||||||
/** Proves that a Settings suffix cannot bypass the outbound raw-type leak guard. */
|
/** Proves that a Settings suffix cannot bypass the outbound raw-type leak guard. */
|
||||||
public class EvilSettings {
|
public class EvilSettings implements RawLeakPortFixture {
|
||||||
|
|
||||||
public RawExternalResponseFixture leak() {
|
@Override
|
||||||
|
public RawExternalResponseFixture leakRaw() {
|
||||||
return new RawExternalResponseFixture();
|
return new RawExternalResponseFixture();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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();
|
||||||
|
}
|
||||||
@@ -74,6 +74,29 @@ Package root: `dev.caskeleton.application`.
|
|||||||
|
|
||||||
## Notification R1 application boundary
|
## 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.
|
||||||
|
|
||||||
|
## Notification R1 application boundary
|
||||||
|
|
||||||
- `dev.caskeleton.application.notification` owns only framework-free semantic values, code-owned
|
- `dev.caskeleton.application.notification` owns only framework-free semantic values, code-owned
|
||||||
kind policy, narrow outbound ports, dispatch/receipt/admission/reconciliation orchestration and
|
kind policy, narrow outbound ports, dispatch/receipt/admission/reconciliation orchestration and
|
||||||
writer-cutover command contracts.
|
writer-cutover command contracts.
|
||||||
|
|||||||
+4
@@ -27,6 +27,10 @@ public final class NotificationCapabilityCompatibilityValidator {
|
|||||||
reasons,
|
reasons,
|
||||||
provider.maximumTargets() < policy.maxTargetsPerRecipient(),
|
provider.maximumTargets() < policy.maxTargetsPerRecipient(),
|
||||||
"PROVIDER_TARGET_BOUND_INSUFFICIENT");
|
"PROVIDER_TARGET_BOUND_INSUFFICIENT");
|
||||||
|
addIf(
|
||||||
|
reasons,
|
||||||
|
policy.maxReconcileCalls() > 0 && !provider.reconciliationSupported(),
|
||||||
|
"PROVIDER_RECONCILIATION_UNSUPPORTED");
|
||||||
if (policy.mode() == NotificationMode.DURABLE_ASYNC) {
|
if (policy.mode() == NotificationMode.DURABLE_ASYNC) {
|
||||||
addIf(
|
addIf(
|
||||||
reasons,
|
reasons,
|
||||||
|
|||||||
+69
-6
@@ -146,6 +146,20 @@ public interface NotificationDeliveryStorePort {
|
|||||||
&& providerOutcome.retryDisposition() != RetryDisposition.PARK_BINDING) {
|
&& providerOutcome.retryDisposition() != RetryDisposition.PARK_BINDING) {
|
||||||
throw new IllegalArgumentException("PARKED_BINDING requires PARK_BINDING disposition");
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,19 +182,46 @@ public interface NotificationDeliveryStorePort {
|
|||||||
NotificationDeliveryId deliveryId,
|
NotificationDeliveryId deliveryId,
|
||||||
String executionToken,
|
String executionToken,
|
||||||
long expectedRowVersion,
|
long expectedRowVersion,
|
||||||
String providerMessageReference,
|
NotificationRouteId routeId,
|
||||||
|
int routeRevision,
|
||||||
|
String bindingDigest,
|
||||||
|
int targetOrdinal,
|
||||||
|
String targetReference,
|
||||||
|
String providerCapabilityReference,
|
||||||
|
String providerBindingRevision,
|
||||||
|
String credentialGeneration,
|
||||||
|
String lookupReference,
|
||||||
|
ReconciliationLookupKind lookupKind,
|
||||||
Instant absoluteDeadline) {
|
Instant absoluteDeadline) {
|
||||||
|
|
||||||
public ReconciliationClaim {
|
public ReconciliationClaim {
|
||||||
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
|
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
|
||||||
executionToken =
|
executionToken =
|
||||||
NotificationIntentId.requireOpaque("reconciliation execution token", executionToken);
|
NotificationIntentId.requireOpaque("reconciliation execution token", executionToken);
|
||||||
if (expectedRowVersion < 0) {
|
if (expectedRowVersion < 0 || routeRevision < 1 || targetOrdinal < 0 || targetOrdinal > 15) {
|
||||||
throw new IllegalArgumentException("reconciliation row version must be non-negative");
|
throw new IllegalArgumentException(
|
||||||
|
"reconciliation row version, route revision and target ordinal are invalid");
|
||||||
}
|
}
|
||||||
providerMessageReference =
|
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(
|
NotificationIntentId.requireOpaque(
|
||||||
"provider message reference", providerMessageReference);
|
"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");
|
Objects.requireNonNull(absoluteDeadline, "reconciliation deadline must be non-null");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,12 +230,34 @@ public interface NotificationDeliveryStorePort {
|
|||||||
return "ReconciliationClaim[deliveryId=<redacted>, executionToken=<redacted>, "
|
return "ReconciliationClaim[deliveryId=<redacted>, executionToken=<redacted>, "
|
||||||
+ "expectedRowVersion="
|
+ "expectedRowVersion="
|
||||||
+ expectedRowVersion
|
+ expectedRowVersion
|
||||||
+ ", providerMessageReference=<redacted>, absoluteDeadline="
|
+ ", routeId="
|
||||||
|
+ routeId
|
||||||
|
+ ", routeRevision="
|
||||||
|
+ routeRevision
|
||||||
|
+ ", bindingDigest="
|
||||||
|
+ bindingDigest
|
||||||
|
+ ", targetOrdinal="
|
||||||
|
+ targetOrdinal
|
||||||
|
+ ", targetReference=<redacted>, providerCapabilityReference="
|
||||||
|
+ providerCapabilityReference
|
||||||
|
+ ", providerBindingRevision="
|
||||||
|
+ providerBindingRevision
|
||||||
|
+ ", credentialGeneration="
|
||||||
|
+ credentialGeneration
|
||||||
|
+ ", lookupReference=<redacted>, lookupKind="
|
||||||
|
+ lookupKind
|
||||||
|
+ ", absoluteDeadline="
|
||||||
+ absoluteDeadline
|
+ absoluteDeadline
|
||||||
+ "]";
|
+ "]";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ReconciliationLookupKind {
|
||||||
|
PRE_SEND_CORRELATION,
|
||||||
|
CLIENT_OPERATION_KEY,
|
||||||
|
MESSAGE_REFERENCE
|
||||||
|
}
|
||||||
|
|
||||||
enum ReconciliationFinalizationResult {
|
enum ReconciliationFinalizationResult {
|
||||||
APPLIED,
|
APPLIED,
|
||||||
LATE_EXACT_APPLIED,
|
LATE_EXACT_APPLIED,
|
||||||
|
|||||||
+11
-3
@@ -134,7 +134,7 @@ public final class NotificationDispatchUseCase
|
|||||||
|
|
||||||
NotificationDeliveryStorePort.AttemptFinalization finalization =
|
NotificationDeliveryStorePort.AttemptFinalization finalization =
|
||||||
new NotificationDeliveryStorePort.AttemptFinalization(
|
new NotificationDeliveryStorePort.AttemptFinalization(
|
||||||
outcome, terminalState(outcome), fallbackEligible(outcome), parkResult);
|
outcome, terminalState(outcome, parkResult), fallbackEligible(outcome), parkResult);
|
||||||
NotificationDeliveryStorePort.FinalizationResult result =
|
NotificationDeliveryStorePort.FinalizationResult result =
|
||||||
Objects.requireNonNull(
|
Objects.requireNonNull(
|
||||||
store.finalizeAttempt(attempt, finalization, clock.instant()),
|
store.finalizeAttempt(attempt, finalization, clock.instant()),
|
||||||
@@ -143,7 +143,7 @@ public final class NotificationDispatchUseCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static NotificationDeliveryStorePort.TerminalState terminalState(
|
private static NotificationDeliveryStorePort.TerminalState terminalState(
|
||||||
ProviderAttemptOutcome outcome) {
|
ProviderAttemptOutcome outcome, NotificationAdmissionReadinessPort.ParkResult parkResult) {
|
||||||
if (outcome.submissionCertainty() == SubmissionCertainty.PROVIDER_ACCEPTED) {
|
if (outcome.submissionCertainty() == SubmissionCertainty.PROVIDER_ACCEPTED) {
|
||||||
return NotificationDeliveryStorePort.TerminalState.ACCEPTED;
|
return NotificationDeliveryStorePort.TerminalState.ACCEPTED;
|
||||||
}
|
}
|
||||||
@@ -152,7 +152,15 @@ public final class NotificationDispatchUseCase
|
|||||||
}
|
}
|
||||||
return switch (outcome.retryDisposition()) {
|
return switch (outcome.retryDisposition()) {
|
||||||
case RETRY_AT -> NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED;
|
case RETRY_AT -> NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED;
|
||||||
case PARK_BINDING -> NotificationDeliveryStorePort.TerminalState.PARKED_BINDING;
|
case PARK_BINDING ->
|
||||||
|
switch (parkResult) {
|
||||||
|
case PARKED, ALREADY_PARKED ->
|
||||||
|
NotificationDeliveryStorePort.TerminalState.PARKED_BINDING;
|
||||||
|
case STALE_GENERATION -> NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED;
|
||||||
|
case NOT_REQUESTED ->
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"PARK_BINDING outcome requires an admission park result");
|
||||||
|
};
|
||||||
case TERMINAL -> NotificationDeliveryStorePort.TerminalState.TERMINAL_FAILURE;
|
case TERMINAL -> NotificationDeliveryStorePort.TerminalState.TERMINAL_FAILURE;
|
||||||
case NOT_APPLICABLE ->
|
case NOT_APPLICABLE ->
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
|
|||||||
+132
-5
@@ -1,6 +1,10 @@
|
|||||||
package dev.caskeleton.application.notification;
|
package dev.caskeleton.application.notification;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -10,6 +14,7 @@ public record NotificationFrozenPlan(
|
|||||||
NotificationIntentId intentId,
|
NotificationIntentId intentId,
|
||||||
NotificationKindPolicy policy,
|
NotificationKindPolicy policy,
|
||||||
Locale selectedLocale,
|
Locale selectedLocale,
|
||||||
|
BindingSnapshot binding,
|
||||||
NotificationRecipientReference recipient,
|
NotificationRecipientReference recipient,
|
||||||
NotificationTemplateParameters parameters,
|
NotificationTemplateParameters parameters,
|
||||||
String idempotencyScope,
|
String idempotencyScope,
|
||||||
@@ -24,33 +29,44 @@ public record NotificationFrozenPlan(
|
|||||||
Objects.requireNonNull(intentId, "notification intent ID must be non-null");
|
Objects.requireNonNull(intentId, "notification intent ID must be non-null");
|
||||||
Objects.requireNonNull(policy, "notification kind policy must be non-null");
|
Objects.requireNonNull(policy, "notification kind policy must be non-null");
|
||||||
selectedLocale = NotificationIntentDraft.requireLocale("selected locale", selectedLocale);
|
selectedLocale = NotificationIntentDraft.requireLocale("selected locale", selectedLocale);
|
||||||
|
Objects.requireNonNull(binding, "notification binding snapshot must be non-null");
|
||||||
Objects.requireNonNull(recipient, "notification recipient must be non-null");
|
Objects.requireNonNull(recipient, "notification recipient must be non-null");
|
||||||
Objects.requireNonNull(parameters, "notification template parameters must be non-null");
|
Objects.requireNonNull(parameters, "notification template parameters must be non-null");
|
||||||
idempotencyScope =
|
idempotencyScope =
|
||||||
NotificationIntentId.requireOpaque("notification idempotency scope", idempotencyScope);
|
NotificationIntentId.requireOpaque("notification idempotency scope", idempotencyScope);
|
||||||
sourceOperationId =
|
sourceOperationId =
|
||||||
NotificationIntentId.requireOpaque("notification source operation ID", sourceOperationId);
|
NotificationIntentId.requireOpaque("notification source operation ID", sourceOperationId);
|
||||||
Objects.requireNonNull(tenantReference, "tenant reference container must be non-null");
|
tenantReference = requireOptionalOpaque("tenant reference", tenantReference);
|
||||||
correlationReference =
|
correlationReference =
|
||||||
NotificationIntentId.requireOpaque(
|
NotificationIntentId.requireOpaque(
|
||||||
"notification correlation reference", correlationReference);
|
"notification correlation reference", correlationReference);
|
||||||
Objects.requireNonNull(causationReference, "causation reference container must be non-null");
|
causationReference = requireOptionalOpaque("causation reference", causationReference);
|
||||||
Objects.requireNonNull(notBefore, "notification not-before time must be non-null");
|
Objects.requireNonNull(notBefore, "notification not-before time must be non-null");
|
||||||
Objects.requireNonNull(expiresAt, "notification expiry time must be non-null");
|
Objects.requireNonNull(expiresAt, "notification expiry time must be non-null");
|
||||||
if (recipient.channel() != policy.channel()) {
|
if (recipient.channel() != policy.channel()) {
|
||||||
throw new IllegalArgumentException("recipient channel must match notification kind channel");
|
throw new IllegalArgumentException("recipient channel must match notification kind channel");
|
||||||
}
|
}
|
||||||
if (!expiresAt.isAfter(notBefore)) {
|
if (binding.targets().size() != policy.maxTargetsPerRecipient()) {
|
||||||
throw new IllegalArgumentException("notification expiry must be after not-before");
|
throw new IllegalArgumentException(
|
||||||
|
"frozen binding target count must match the code-owned policy target bound");
|
||||||
|
}
|
||||||
|
Duration lifetime = Duration.between(notBefore, expiresAt);
|
||||||
|
if (lifetime.isZero()
|
||||||
|
|| lifetime.isNegative()
|
||||||
|
|| lifetime.compareTo(policy.maxElapsedRetryHorizon()) > 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"notification expiry must be after not-before and within the policy retry horizon");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static NotificationFrozenPlan from(NotificationIntentDraft draft, Locale selectedLocale) {
|
public static NotificationFrozenPlan from(
|
||||||
|
NotificationIntentDraft draft, Locale selectedLocale, BindingSnapshot binding) {
|
||||||
Objects.requireNonNull(draft, "notification intent draft must be non-null");
|
Objects.requireNonNull(draft, "notification intent draft must be non-null");
|
||||||
return new NotificationFrozenPlan(
|
return new NotificationFrozenPlan(
|
||||||
draft.intentId(),
|
draft.intentId(),
|
||||||
draft.policy(),
|
draft.policy(),
|
||||||
selectedLocale,
|
selectedLocale,
|
||||||
|
binding,
|
||||||
draft.recipient(),
|
draft.recipient(),
|
||||||
draft.parameters(),
|
draft.parameters(),
|
||||||
draft.idempotencyScope(),
|
draft.idempotencyScope(),
|
||||||
@@ -70,6 +86,11 @@ public record NotificationFrozenPlan(
|
|||||||
return policy.routeId();
|
return policy.routeId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Optional<String> requireOptionalOpaque(String field, Optional<String> reference) {
|
||||||
|
Objects.requireNonNull(reference, field + " container must be non-null");
|
||||||
|
return reference.map(value -> NotificationIntentId.requireOpaque(field, value));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return "NotificationFrozenPlan[intentId="
|
return "NotificationFrozenPlan[intentId="
|
||||||
@@ -80,10 +101,116 @@ public record NotificationFrozenPlan(
|
|||||||
+ policy.policyRevision()
|
+ policy.policyRevision()
|
||||||
+ ", selectedLocale="
|
+ ", selectedLocale="
|
||||||
+ selectedLocale.toLanguageTag()
|
+ selectedLocale.toLanguageTag()
|
||||||
|
+ ", routeRevision="
|
||||||
|
+ binding.routeRevision()
|
||||||
|
+ ", bindingDigest="
|
||||||
|
+ binding.bindingDigest()
|
||||||
|
+ ", rendererRevision="
|
||||||
|
+ binding.rendererRevision()
|
||||||
|
+ ", targets=<redacted>"
|
||||||
+ ", recipient=<redacted>, parameters=<redacted>, context=<redacted>, notBefore="
|
+ ", recipient=<redacted>, parameters=<redacted>, context=<redacted>, notBefore="
|
||||||
+ notBefore
|
+ notBefore
|
||||||
+ ", expiresAt="
|
+ ", expiresAt="
|
||||||
+ expiresAt
|
+ expiresAt
|
||||||
+ "]";
|
+ "]";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Provider-neutral immutable execution graph persisted with the logical intent. */
|
||||||
|
public record BindingSnapshot(
|
||||||
|
int routeRevision,
|
||||||
|
String bindingDigest,
|
||||||
|
String templateChecksum,
|
||||||
|
String rendererRevision,
|
||||||
|
List<FrozenTarget> targets,
|
||||||
|
boolean receiptRequired,
|
||||||
|
Duration perAttemptDeadline) {
|
||||||
|
|
||||||
|
private static final Duration MAXIMUM_ATTEMPT_DEADLINE = Duration.ofMinutes(5);
|
||||||
|
|
||||||
|
public BindingSnapshot {
|
||||||
|
if (routeRevision < 1 || routeRevision > 1_000_000) {
|
||||||
|
throw new IllegalArgumentException("frozen route revision must be in 1..1000000");
|
||||||
|
}
|
||||||
|
bindingDigest = requireDigest("notification binding digest", bindingDigest);
|
||||||
|
templateChecksum = requireDigest("notification template checksum", templateChecksum);
|
||||||
|
rendererRevision =
|
||||||
|
NotificationIntentId.requireSlug("notification renderer revision", rendererRevision);
|
||||||
|
Objects.requireNonNull(targets, "frozen notification targets must be non-null");
|
||||||
|
targets =
|
||||||
|
targets.stream()
|
||||||
|
.map(target -> Objects.requireNonNull(target, "frozen target must be non-null"))
|
||||||
|
.sorted(Comparator.comparingInt(FrozenTarget::ordinal))
|
||||||
|
.toList();
|
||||||
|
if (targets.isEmpty() || targets.size() > 16) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"frozen notification targets must contain 1..16 entries");
|
||||||
|
}
|
||||||
|
if (new HashSet<>(targets.stream().map(FrozenTarget::targetReference).toList()).size()
|
||||||
|
!= targets.size()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"frozen notification targets contain duplicate references");
|
||||||
|
}
|
||||||
|
for (int index = 0; index < targets.size(); index++) {
|
||||||
|
if (targets.get(index).ordinal() != index) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"frozen notification target ordinals must be contiguous from zero");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Objects.requireNonNull(
|
||||||
|
perAttemptDeadline, "notification per-attempt deadline must be non-null");
|
||||||
|
if (perAttemptDeadline.isZero()
|
||||||
|
|| perAttemptDeadline.isNegative()
|
||||||
|
|| perAttemptDeadline.compareTo(MAXIMUM_ATTEMPT_DEADLINE) > 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"notification per-attempt deadline must be positive and at most five minutes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireDigest(String field, String digest) {
|
||||||
|
if (digest == null || !digest.matches("[0-9a-f]{64}")) {
|
||||||
|
throw new IllegalArgumentException(field + " must be a lowercase SHA-256 digest");
|
||||||
|
}
|
||||||
|
return digest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opaque provider-leg identity; credentials, endpoints and SDK types are deliberately absent. */
|
||||||
|
public record FrozenTarget(
|
||||||
|
int ordinal,
|
||||||
|
String targetReference,
|
||||||
|
String providerCapabilityReference,
|
||||||
|
String providerBindingRevision,
|
||||||
|
String credentialGeneration) {
|
||||||
|
|
||||||
|
public FrozenTarget {
|
||||||
|
if (ordinal < 0 || ordinal > 15) {
|
||||||
|
throw new IllegalArgumentException("frozen notification target ordinal must be in 0..15");
|
||||||
|
}
|
||||||
|
targetReference =
|
||||||
|
NotificationIntentId.requireOpaque(
|
||||||
|
"frozen notification target reference", targetReference);
|
||||||
|
providerCapabilityReference =
|
||||||
|
NotificationIntentId.requireOpaque(
|
||||||
|
"frozen provider capability reference", providerCapabilityReference);
|
||||||
|
providerBindingRevision =
|
||||||
|
NotificationIntentId.requireOpaque(
|
||||||
|
"frozen provider binding revision", providerBindingRevision);
|
||||||
|
credentialGeneration =
|
||||||
|
NotificationIntentId.requireSlug(
|
||||||
|
"frozen provider credential generation", credentialGeneration);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "FrozenTarget[ordinal="
|
||||||
|
+ ordinal
|
||||||
|
+ ", targetReference=<redacted>, providerCapabilityReference="
|
||||||
|
+ providerCapabilityReference
|
||||||
|
+ ", providerBindingRevision="
|
||||||
|
+ providerBindingRevision
|
||||||
|
+ ", credentialGeneration="
|
||||||
|
+ credentialGeneration
|
||||||
|
+ "]";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -8,11 +8,12 @@ public record NotificationMaintenanceCommand(
|
|||||||
implements Command {
|
implements Command {
|
||||||
|
|
||||||
public NotificationMaintenanceCommand {
|
public NotificationMaintenanceCommand {
|
||||||
|
long total = (long) maximumExpiredIntents + maximumPayloadRedactions + maximumExpiredReceipts;
|
||||||
if (maximumExpiredIntents < 0
|
if (maximumExpiredIntents < 0
|
||||||
|| maximumPayloadRedactions < 0
|
|| maximumPayloadRedactions < 0
|
||||||
|| maximumExpiredReceipts < 0
|
|| maximumExpiredReceipts < 0
|
||||||
|| maximumExpiredIntents + maximumPayloadRedactions + maximumExpiredReceipts < 1
|
|| total < 1
|
||||||
|| maximumExpiredIntents + maximumPayloadRedactions + maximumExpiredReceipts > 100) {
|
|| total > 100) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"notification maintenance total mutation bound must be in 1..100");
|
"notification maintenance total mutation bound must be in 1..100");
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ public record NotificationMaintenanceResult(
|
|||||||
int expiredIntentCount, int redactedPayloadCount, int expiredReceiptCount) {
|
int expiredIntentCount, int redactedPayloadCount, int expiredReceiptCount) {
|
||||||
|
|
||||||
public NotificationMaintenanceResult {
|
public NotificationMaintenanceResult {
|
||||||
int total = expiredIntentCount + redactedPayloadCount + expiredReceiptCount;
|
long total = (long) expiredIntentCount + redactedPayloadCount + expiredReceiptCount;
|
||||||
if (expiredIntentCount < 0
|
if (expiredIntentCount < 0
|
||||||
|| redactedPayloadCount < 0
|
|| redactedPayloadCount < 0
|
||||||
|| expiredReceiptCount < 0
|
|| expiredReceiptCount < 0
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ public interface NotificationMaintenanceStorePort {
|
|||||||
record MutationResult(int expiredIntentCount, int redactedPayloadCount, int expiredReceiptCount) {
|
record MutationResult(int expiredIntentCount, int redactedPayloadCount, int expiredReceiptCount) {
|
||||||
|
|
||||||
public MutationResult {
|
public MutationResult {
|
||||||
int total = expiredIntentCount + redactedPayloadCount + expiredReceiptCount;
|
long total = (long) expiredIntentCount + redactedPayloadCount + expiredReceiptCount;
|
||||||
if (expiredIntentCount < 0
|
if (expiredIntentCount < 0
|
||||||
|| redactedPayloadCount < 0
|
|| redactedPayloadCount < 0
|
||||||
|| expiredReceiptCount < 0
|
|| expiredReceiptCount < 0
|
||||||
|
|||||||
+23
@@ -88,6 +88,29 @@ class NotificationCapabilityCompatibilityValidatorTest {
|
|||||||
.hasMessageContaining("NOTIFICATION_CAPABILITY_INCOMPATIBLE");
|
.hasMessageContaining("NOTIFICATION_CAPABILITY_INCOMPATIBLE");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reconciliationPolicyRequiresProviderReconciliationCapability() {
|
||||||
|
NotificationKindPolicy policy = policy();
|
||||||
|
NotificationProviderCapabilityDescriptor withoutReconciliation =
|
||||||
|
new NotificationProviderCapabilityDescriptor(
|
||||||
|
"provider-capability-42",
|
||||||
|
NotificationChannel.EMAIL,
|
||||||
|
Set.of(NotificationMode.DURABLE_ASYNC),
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
16,
|
||||||
|
1_000_000);
|
||||||
|
|
||||||
|
NotificationCapabilityCompatibilityValidator.Compatibility result =
|
||||||
|
new NotificationCapabilityCompatibilityValidator()
|
||||||
|
.validate(policy, withoutReconciliation, store(policy), Optional.of(ingress()), true);
|
||||||
|
|
||||||
|
assertThat(result.compatible()).isFalse();
|
||||||
|
assertThat(result.reasonCodes())
|
||||||
|
.containsExactly(new NotificationReasonCode("PROVIDER_RECONCILIATION_UNSUPPORTED"));
|
||||||
|
}
|
||||||
|
|
||||||
private static NotificationKindPolicy policy() {
|
private static NotificationKindPolicy policy() {
|
||||||
return new NotificationKindPolicy(
|
return new NotificationKindPolicy(
|
||||||
new NotificationKindId("security-alert"),
|
new NotificationKindId("security-alert"),
|
||||||
|
|||||||
+32
-1
@@ -151,6 +151,34 @@ class NotificationDispatchUseCaseTest {
|
|||||||
assertThat(result.parkedCount()).isEqualTo(1);
|
assertThat(result.parkedCount()).isEqualTo(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void staleParkGenerationRequeuesTheLegInsteadOfStrandingItAsParked() {
|
||||||
|
List<String> trace = new ArrayList<>();
|
||||||
|
RecordingStore store = new RecordingStore(trace, sampleClaim(), finalizationApplied());
|
||||||
|
NotificationDispatchResult result =
|
||||||
|
new NotificationDispatchUseCase(
|
||||||
|
store,
|
||||||
|
attempt ->
|
||||||
|
new ProviderAttemptOutcome(
|
||||||
|
SubmissionCertainty.DEFINITELY_NOT_APPLIED,
|
||||||
|
RetryDisposition.PARK_BINDING,
|
||||||
|
NotificationFaultScope.PROVIDER_BINDING,
|
||||||
|
new NotificationReasonCode("PROVIDER_AUTH_REJECTED"),
|
||||||
|
Optional.empty(),
|
||||||
|
"correlation-42",
|
||||||
|
Optional.empty()),
|
||||||
|
request -> NotificationAdmissionReadinessPort.ParkResult.STALE_GENERATION,
|
||||||
|
new TrackingTransactionPort(trace),
|
||||||
|
Clock.fixed(NOW, ZoneOffset.UTC))
|
||||||
|
.handle(new NotificationDispatchCommand(1));
|
||||||
|
|
||||||
|
assertThat(store.finalization.parkResult())
|
||||||
|
.isEqualTo(NotificationAdmissionReadinessPort.ParkResult.STALE_GENERATION);
|
||||||
|
assertThat(store.finalization.terminalState())
|
||||||
|
.isEqualTo(NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED);
|
||||||
|
assertThat(result.parkedCount()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void exactLateResultIsCountedWithoutBlindProviderRetry() {
|
void exactLateResultIsCountedWithoutBlindProviderRetry() {
|
||||||
RecordingStore store =
|
RecordingStore store =
|
||||||
@@ -251,7 +279,10 @@ class NotificationDispatchUseCaseTest {
|
|||||||
Optional.empty(),
|
Optional.empty(),
|
||||||
NOW,
|
NOW,
|
||||||
NOW.plusSeconds(600));
|
NOW.plusSeconds(600));
|
||||||
return NotificationFrozenPlan.from(draft, java.util.Locale.ENGLISH);
|
return NotificationFrozenPlan.from(
|
||||||
|
draft,
|
||||||
|
java.util.Locale.ENGLISH,
|
||||||
|
NotificationTestFixtures.binding(draft.policy().channel()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ProviderAttemptOutcome accepted() {
|
private static ProviderAttemptOutcome accepted() {
|
||||||
|
|||||||
+11
@@ -33,6 +33,17 @@ class NotificationMaintenanceUseCaseTest {
|
|||||||
assertThat(result).isEqualTo(new NotificationMaintenanceResult(3, 2, 1));
|
assertThat(result).isEqualTo(new NotificationMaintenanceResult(3, 2, 1));
|
||||||
assertThatThrownBy(() -> new NotificationMaintenanceCommand(50, 50, 1))
|
assertThatThrownBy(() -> new NotificationMaintenanceCommand(50, 50, 1))
|
||||||
.isInstanceOf(IllegalArgumentException.class);
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
assertThatThrownBy(
|
||||||
|
() -> new NotificationMaintenanceCommand(Integer.MAX_VALUE, Integer.MAX_VALUE, 3))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
assertThatThrownBy(
|
||||||
|
() ->
|
||||||
|
new NotificationMaintenanceStorePort.MutationResult(
|
||||||
|
Integer.MAX_VALUE, Integer.MAX_VALUE, 3))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
assertThatThrownBy(
|
||||||
|
() -> new NotificationMaintenanceResult(Integer.MAX_VALUE, Integer.MAX_VALUE, 3))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class TrackingTransactions implements TransactionPort {
|
private static final class TrackingTransactions implements TransactionPort {
|
||||||
|
|||||||
+7
-2
@@ -26,7 +26,10 @@ class NotificationPlanningBoundaryTest {
|
|||||||
NotificationPlanPort planner =
|
NotificationPlanPort planner =
|
||||||
requested ->
|
requested ->
|
||||||
new NotificationPlanningResult.Planned(
|
new NotificationPlanningResult.Planned(
|
||||||
NotificationFrozenPlan.from(requested, Locale.ENGLISH));
|
NotificationFrozenPlan.from(
|
||||||
|
requested,
|
||||||
|
Locale.ENGLISH,
|
||||||
|
NotificationTestFixtures.binding(requested.policy().channel())));
|
||||||
|
|
||||||
NotificationPlanningResult result = planner.plan(draft);
|
NotificationPlanningResult result = planner.plan(draft);
|
||||||
|
|
||||||
@@ -46,7 +49,9 @@ class NotificationPlanningBoundaryTest {
|
|||||||
"recipient-ref-42",
|
"recipient-ref-42",
|
||||||
"source-operation-42",
|
"source-operation-42",
|
||||||
Instant.parse("2026-07-28T00:00:00Z"));
|
Instant.parse("2026-07-28T00:00:00Z"));
|
||||||
NotificationFrozenPlan plan = NotificationFrozenPlan.from(draft, Locale.ENGLISH);
|
NotificationFrozenPlan plan =
|
||||||
|
NotificationFrozenPlan.from(
|
||||||
|
draft, Locale.ENGLISH, NotificationTestFixtures.binding(draft.policy().channel()));
|
||||||
AtomicReference<NotificationFrozenPlan> appended = new AtomicReference<>();
|
AtomicReference<NotificationFrozenPlan> appended = new AtomicReference<>();
|
||||||
AtomicReference<NotificationFrozenPlan> attempted = new AtomicReference<>();
|
AtomicReference<NotificationFrozenPlan> attempted = new AtomicReference<>();
|
||||||
NotificationIntentAppendPort appendPort =
|
NotificationIntentAppendPort appendPort =
|
||||||
|
|||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package dev.caskeleton.application.notification;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
final class NotificationTestFixtures {
|
||||||
|
|
||||||
|
private NotificationTestFixtures() {}
|
||||||
|
|
||||||
|
static NotificationFrozenPlan.BindingSnapshot binding(NotificationChannel channel) {
|
||||||
|
String capability =
|
||||||
|
channel == NotificationChannel.EMAIL
|
||||||
|
? "aws-ses-v2-durable-single-local-sns-v1"
|
||||||
|
: "slack-web-api-durable-single-local-v1";
|
||||||
|
return new NotificationFrozenPlan.BindingSnapshot(
|
||||||
|
3,
|
||||||
|
"a".repeat(64),
|
||||||
|
"b".repeat(64),
|
||||||
|
"renderer-r3",
|
||||||
|
List.of(
|
||||||
|
new NotificationFrozenPlan.FrozenTarget(
|
||||||
|
0, "target-r3", capability, "provider-binding-r3", "credential-r3")),
|
||||||
|
channel == NotificationChannel.EMAIL,
|
||||||
|
Duration.ofSeconds(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
-1
@@ -193,14 +193,93 @@ class NotificationValueContractTest {
|
|||||||
notBefore,
|
notBefore,
|
||||||
notBefore.plusSeconds(60));
|
notBefore.plusSeconds(60));
|
||||||
|
|
||||||
NotificationFrozenPlan plan = NotificationFrozenPlan.from(draft, Locale.forLanguageTag("en"));
|
NotificationFrozenPlan plan =
|
||||||
|
NotificationFrozenPlan.from(
|
||||||
|
draft,
|
||||||
|
Locale.forLanguageTag("en"),
|
||||||
|
NotificationTestFixtures.binding(draft.policy().channel()));
|
||||||
|
|
||||||
assertThat(plan.selectedLocale()).isEqualTo(Locale.ENGLISH);
|
assertThat(plan.selectedLocale()).isEqualTo(Locale.ENGLISH);
|
||||||
assertThat(plan.mode()).isEqualTo(NotificationMode.DURABLE_ASYNC);
|
assertThat(plan.mode()).isEqualTo(NotificationMode.DURABLE_ASYNC);
|
||||||
|
assertThat(plan.binding().targets()).isUnmodifiable();
|
||||||
assertThat(plan.routeId()).isEqualTo(new NotificationRouteId("email-primary"));
|
assertThat(plan.routeId()).isEqualTo(new NotificationRouteId("email-primary"));
|
||||||
assertThat(plan.toString()).doesNotContain("recipient-ref-42").doesNotContain("Ada");
|
assertThat(plan.toString()).doesNotContain("recipient-ref-42").doesNotContain("Ada");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicFrozenPlanConstructorPreservesDraftContextAndRetryHorizonInvariants() {
|
||||||
|
NotificationKindPolicy policy = durablePolicy(Duration.ofHours(1));
|
||||||
|
Instant notBefore = Instant.parse("2026-07-28T00:00:00Z");
|
||||||
|
NotificationFrozenPlan valid =
|
||||||
|
new NotificationFrozenPlan(
|
||||||
|
new NotificationIntentId("intent-42"),
|
||||||
|
policy,
|
||||||
|
Locale.ENGLISH,
|
||||||
|
NotificationTestFixtures.binding(policy.channel()),
|
||||||
|
new EmailRecipientReference("recipient-ref-42"),
|
||||||
|
new NotificationTemplateParameters(
|
||||||
|
Map.of("displayName", new NotificationTemplateValue.SafeText("Ada"))),
|
||||||
|
"password-reset",
|
||||||
|
"source-operation-42",
|
||||||
|
Optional.empty(),
|
||||||
|
"correlation-42",
|
||||||
|
Optional.empty(),
|
||||||
|
notBefore,
|
||||||
|
notBefore.plusSeconds(60));
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() ->
|
||||||
|
new NotificationFrozenPlan(
|
||||||
|
valid.intentId(),
|
||||||
|
policy,
|
||||||
|
valid.selectedLocale(),
|
||||||
|
valid.binding(),
|
||||||
|
valid.recipient(),
|
||||||
|
valid.parameters(),
|
||||||
|
valid.idempotencyScope(),
|
||||||
|
valid.sourceOperationId(),
|
||||||
|
Optional.of(" "),
|
||||||
|
valid.correlationReference(),
|
||||||
|
valid.causationReference(),
|
||||||
|
notBefore,
|
||||||
|
notBefore.plusSeconds(60)))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
assertThatThrownBy(
|
||||||
|
() ->
|
||||||
|
new NotificationFrozenPlan(
|
||||||
|
valid.intentId(),
|
||||||
|
policy,
|
||||||
|
valid.selectedLocale(),
|
||||||
|
valid.binding(),
|
||||||
|
valid.recipient(),
|
||||||
|
valid.parameters(),
|
||||||
|
valid.idempotencyScope(),
|
||||||
|
valid.sourceOperationId(),
|
||||||
|
valid.tenantReference(),
|
||||||
|
valid.correlationReference(),
|
||||||
|
Optional.of("\n"),
|
||||||
|
notBefore,
|
||||||
|
notBefore.plusSeconds(60)))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
assertThatThrownBy(
|
||||||
|
() ->
|
||||||
|
new NotificationFrozenPlan(
|
||||||
|
valid.intentId(),
|
||||||
|
policy,
|
||||||
|
valid.selectedLocale(),
|
||||||
|
valid.binding(),
|
||||||
|
valid.recipient(),
|
||||||
|
valid.parameters(),
|
||||||
|
valid.idempotencyScope(),
|
||||||
|
valid.sourceOperationId(),
|
||||||
|
valid.tenantReference(),
|
||||||
|
valid.correlationReference(),
|
||||||
|
valid.causationReference(),
|
||||||
|
notBefore,
|
||||||
|
notBefore.plus(Duration.ofHours(2))))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
|
||||||
private static NotificationKindPolicy durablePolicy(Duration retryHorizon) {
|
private static NotificationKindPolicy durablePolicy(Duration retryHorizon) {
|
||||||
return new NotificationKindPolicy(
|
return new NotificationKindPolicy(
|
||||||
new NotificationKindId("password-reset"),
|
new NotificationKindId("password-reset"),
|
||||||
|
|||||||
+9
@@ -81,7 +81,16 @@ class ReconcileNotificationDeliveriesUseCaseTest {
|
|||||||
new NotificationDeliveryId("delivery-42"),
|
new NotificationDeliveryId("delivery-42"),
|
||||||
"reconcile-token-42",
|
"reconcile-token-42",
|
||||||
3,
|
3,
|
||||||
|
new NotificationRouteId("security-slack"),
|
||||||
|
3,
|
||||||
|
"a".repeat(64),
|
||||||
|
0,
|
||||||
|
"slack-primary",
|
||||||
|
"slack-web-api-durable-single-local-v1",
|
||||||
|
"slack-binding-r1",
|
||||||
|
"credential-r1",
|
||||||
"provider-message-42",
|
"provider-message-42",
|
||||||
|
NotificationDeliveryStorePort.ReconciliationLookupKind.MESSAGE_REFERENCE,
|
||||||
NOW.plusSeconds(30)));
|
NOW.plusSeconds(30)));
|
||||||
|
|
||||||
private RecordingStore(List<String> trace) {
|
private RecordingStore(List<String> trace) {
|
||||||
|
|||||||
Reference in New Issue
Block a user