feat: jpa, messaging, notification, mongo, graphql 어댑터터 리펙토링
This commit is contained in:
+1
-1
@@ -18,7 +18,7 @@ import java.util.Objects;
|
||||
transactionMode = TransactionMode.WRITE,
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||
public final class ApplyNotificationReceiptUseCase
|
||||
public class ApplyNotificationReceiptUseCase
|
||||
implements CommandUseCase<ApplyNotificationReceiptCommand, ApplyNotificationReceiptResult> {
|
||||
|
||||
private final NotificationReceiptStorePort store;
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import java.util.Objects;
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
|
||||
crossTenantAdmin = true)
|
||||
public final class InitializeNotificationWriterFencesUseCase
|
||||
public class InitializeNotificationWriterFencesUseCase
|
||||
implements CommandUseCase<
|
||||
InitializeNotificationWriterFencesCommand, InitializeNotificationWriterFencesResult> {
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import java.util.Objects;
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
|
||||
externalOutboundAllowed = true,
|
||||
crossTenantAdmin = true)
|
||||
public final class NotificationAdmissionGateUseCase
|
||||
public class NotificationAdmissionGateUseCase
|
||||
implements CommandUseCase<
|
||||
NotificationAdmissionGateCommand, NotificationAdmissionGateUseCase.Result> {
|
||||
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ import java.util.Objects;
|
||||
externalOutboundAllowed = true,
|
||||
sensitiveRead = true,
|
||||
crossTenantAdmin = true)
|
||||
public final class NotificationDispatchUseCase
|
||||
public class NotificationDispatchUseCase
|
||||
implements CommandUseCase<NotificationDispatchCommand, NotificationDispatchResult> {
|
||||
|
||||
private final NotificationDeliveryStorePort store;
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import java.util.Objects;
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
|
||||
crossTenantAdmin = true)
|
||||
public final class NotificationLegacyWriterPermitUseCase
|
||||
public class NotificationLegacyWriterPermitUseCase
|
||||
implements CommandUseCase<
|
||||
NotificationLegacyWriterPermitCommand, NotificationLegacyWriterPermitResult> {
|
||||
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import java.util.Objects;
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
|
||||
crossTenantAdmin = true)
|
||||
public final class NotificationMaintenanceUseCase
|
||||
public class NotificationMaintenanceUseCase
|
||||
implements CommandUseCase<NotificationMaintenanceCommand, NotificationMaintenanceResult> {
|
||||
|
||||
private final NotificationMaintenanceStorePort store;
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import java.util.Objects;
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.READ_REPOSITORY,
|
||||
crossTenantAdmin = true)
|
||||
public final class NotificationOperationsSnapshotUseCase
|
||||
public class NotificationOperationsSnapshotUseCase
|
||||
implements QueryUseCase<NotificationOperationsSnapshotQuery, NotificationOperationsSnapshot> {
|
||||
|
||||
private final NotificationOperationsSnapshotPort snapshots;
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ import java.util.Objects;
|
||||
externalOutboundAllowed = true,
|
||||
sensitiveRead = true,
|
||||
crossTenantAdmin = true)
|
||||
public final class ReconcileNotificationDeliveriesUseCase
|
||||
public class ReconcileNotificationDeliveriesUseCase
|
||||
implements CommandUseCase<
|
||||
ReconcileNotificationDeliveriesCommand, ReconcileNotificationDeliveriesResult> {
|
||||
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ import java.util.Set;
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
|
||||
crossTenantAdmin = true)
|
||||
public final class RecordNotificationWriterQuiescenceAttestationUseCase
|
||||
public class RecordNotificationWriterQuiescenceAttestationUseCase
|
||||
implements CommandUseCase<
|
||||
RecordNotificationWriterQuiescenceAttestationCommand,
|
||||
RecordNotificationWriterQuiescenceAttestationResult> {
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ import java.util.Optional;
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
|
||||
crossTenantAdmin = true)
|
||||
public final class SwitchNotificationWriterOwnershipUseCase
|
||||
public class SwitchNotificationWriterOwnershipUseCase
|
||||
implements CommandUseCase<
|
||||
SwitchNotificationWriterOwnershipCommand, SwitchNotificationWriterOwnershipResult> {
|
||||
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import java.util.Objects;
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
|
||||
crossTenantAdmin = true)
|
||||
public final class TerminalizeExpiredNotificationWriterPermitsUseCase
|
||||
public class TerminalizeExpiredNotificationWriterPermitsUseCase
|
||||
implements CommandUseCase<
|
||||
TerminalizeExpiredNotificationWriterPermitsCommand,
|
||||
TerminalizeExpiredNotificationWriterPermitsResult> {
|
||||
|
||||
+37
@@ -38,6 +38,17 @@ public sealed interface NotificationVariable
|
||||
NotificationVariable.ListValue,
|
||||
NotificationVariable.ObjectValue {
|
||||
|
||||
/**
|
||||
* The value as plain JDK types, for a renderer that speaks maps and lists.
|
||||
*
|
||||
* <p>One direction only. Coming back the other way is what the closed algebra exists to prevent:
|
||||
* {@code "1"} and {@code 1} are distinguishable here and indistinguishable once a renderer has
|
||||
* seen them, which is why the fingerprint is taken over the algebra and never over this.
|
||||
*
|
||||
* @return the plain projection: String, BigDecimal, Boolean, null, List or Map
|
||||
*/
|
||||
Object plain();
|
||||
|
||||
/** Deepest nesting a variable graph may have. */
|
||||
int MAX_DEPTH = 16;
|
||||
|
||||
@@ -59,6 +70,10 @@ public sealed interface NotificationVariable
|
||||
|
||||
/** Text. */
|
||||
record TextValue(String value) implements NotificationVariable {
|
||||
@Override
|
||||
public Object plain() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public TextValue {
|
||||
Objects.requireNonNull(value, "value");
|
||||
@@ -86,6 +101,10 @@ public sealed interface NotificationVariable
|
||||
* decimals, and a fingerprint that cannot tell them apart merges two different requests.
|
||||
*/
|
||||
record NumberValue(BigDecimal value) implements NotificationVariable {
|
||||
@Override
|
||||
public Object plain() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public NumberValue {
|
||||
Objects.requireNonNull(value, "value");
|
||||
@@ -104,6 +123,10 @@ public sealed interface NotificationVariable
|
||||
|
||||
/** A boolean. */
|
||||
record BooleanValue(boolean value) implements NotificationVariable {
|
||||
@Override
|
||||
public Object plain() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int depth() {
|
||||
@@ -113,6 +136,10 @@ public sealed interface NotificationVariable
|
||||
|
||||
/** An explicit absence, distinct from a key that is not present. */
|
||||
record NullValue() implements NotificationVariable {
|
||||
@Override
|
||||
public Object plain() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The single instance. */
|
||||
public static final NullValue INSTANCE = new NullValue();
|
||||
@@ -125,6 +152,10 @@ public sealed interface NotificationVariable
|
||||
|
||||
/** An ordered list; order is part of the identity. */
|
||||
record ListValue(List<NotificationVariable> values) implements NotificationVariable {
|
||||
@Override
|
||||
public Object plain() {
|
||||
return values.stream().map(NotificationVariable::plain).toList();
|
||||
}
|
||||
|
||||
public ListValue {
|
||||
values = List.copyOf(Objects.requireNonNull(values, "values"));
|
||||
@@ -142,6 +173,12 @@ public sealed interface NotificationVariable
|
||||
|
||||
/** A keyed object; iteration order is normalised at encoding time, not here. */
|
||||
record ObjectValue(Map<String, NotificationVariable> values) implements NotificationVariable {
|
||||
@Override
|
||||
public Object plain() {
|
||||
java.util.Map<String, Object> plain = new java.util.LinkedHashMap<>();
|
||||
values.forEach((key, value) -> plain.put(key, value.plain()));
|
||||
return java.util.Collections.unmodifiableMap(plain);
|
||||
}
|
||||
|
||||
public ObjectValue {
|
||||
values = Map.copyOf(Objects.requireNonNull(values, "values"));
|
||||
|
||||
+16
@@ -18,6 +18,22 @@ public abstract class NotificationException extends RuntimeException {
|
||||
this.descriptor = descriptor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception with the failure that caused it.
|
||||
*
|
||||
* <p>The message stays the descriptor's code — low-cardinality and safe to store or log — while
|
||||
* the cause carries the detail. Folding the cause's message into this one would put whatever a
|
||||
* library chose to say, possibly including an address, into every place this exception is
|
||||
* rendered.
|
||||
*
|
||||
* @param descriptor the safe, storable failure description
|
||||
* @param cause the underlying failure
|
||||
*/
|
||||
protected NotificationException(NotificationFailureDescriptor descriptor, Throwable cause) {
|
||||
super(descriptor.code(), cause);
|
||||
this.descriptor = descriptor;
|
||||
}
|
||||
|
||||
/** Stable, low-cardinality metadata describing this failure. */
|
||||
public NotificationFailureDescriptor descriptor() {
|
||||
return descriptor;
|
||||
|
||||
+16
@@ -8,4 +8,20 @@ public class NotificationValidationException extends NotificationException {
|
||||
public NotificationValidationException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception with the failure that caused it.
|
||||
*
|
||||
* <p>The descriptor is what a caller and a stored row see; the cause is what an operator needs.
|
||||
* Without this overload every MIME construction failure arrived as a bare "VALIDATION_FAILED"
|
||||
* with no indication of which of a dozen checks rejected it, which is what a lane found when SMTP
|
||||
* dispatch failed and the only diagnostic available was the exception's own class name.
|
||||
*
|
||||
* @param descriptor the safe, storable failure description
|
||||
* @param cause the underlying failure; never rendered into a caller-visible message
|
||||
*/
|
||||
public NotificationValidationException(
|
||||
NotificationFailureDescriptor descriptor, Throwable cause) {
|
||||
super(descriptor, cause);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ import java.util.Optional;
|
||||
// fetch a provider certificate, which is an outbound call this use case genuinely makes.
|
||||
externalOutboundAllowed = true)
|
||||
@dev.caskeleton.application.security.RequiresPermission("notification-callback:ingest")
|
||||
public final class IngestProviderCallbackApplicationUseCase
|
||||
public class IngestProviderCallbackApplicationUseCase
|
||||
implements dev.caskeleton.application.notification.platform.port.in
|
||||
.IngestProviderCallbackUseCase {
|
||||
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.capability.Idempotency;
|
||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||
import dev.caskeleton.application.notification.platform.api.ContactPointId;
|
||||
import dev.caskeleton.application.notification.platform.api.ContactPointSelector;
|
||||
import dev.caskeleton.application.notification.platform.api.CorrelationId;
|
||||
import dev.caskeleton.application.notification.platform.api.IdempotencyKey;
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationPlan;
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationReceipt;
|
||||
import dev.caskeleton.application.notification.platform.api.RecipientSpec;
|
||||
import dev.caskeleton.application.notification.platform.api.TemplateSelection;
|
||||
import dev.caskeleton.application.notification.platform.api.TenantId;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.ExplicitChannel;
|
||||
import dev.caskeleton.application.notification.platform.contact.ContactPointStatus;
|
||||
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
|
||||
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
|
||||
import dev.caskeleton.application.notification.platform.contact.InAppRecipientRef;
|
||||
import dev.caskeleton.application.notification.platform.contact.PhoneNumber;
|
||||
import dev.caskeleton.application.notification.platform.port.in.AcceptNotificationCommand;
|
||||
import dev.caskeleton.application.notification.platform.port.in.AcceptNotificationUseCase;
|
||||
import dev.caskeleton.application.notification.platform.port.in.SubmitNotificationCommand;
|
||||
import dev.caskeleton.application.notification.platform.port.in.SubmitNotificationUseCase;
|
||||
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
|
||||
import dev.caskeleton.application.notification.platform.security.ProtectedContactPoint;
|
||||
import dev.caskeleton.application.security.RequiresPermission;
|
||||
import dev.caskeleton.application.transaction.TransactionMode;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Accepts a notification addressed by contact point value.
|
||||
*
|
||||
* <p>Two steps, and the order between them is the point: the address is protected and stored as a
|
||||
* contact point <em>first</em>, and only its id ever reaches the plan. So the plan, the request
|
||||
* fingerprint computed from it, and every row written afterwards carry an opaque identity rather
|
||||
* than an address — which is what makes the encryption-at-rest design hold for a caller that starts
|
||||
* with an address instead of a directory.
|
||||
*
|
||||
* <p>Registration is idempotent by keyed fingerprint. Submitting twice to the same address reuses
|
||||
* the stored contact point rather than creating a second one, because two rows for one address
|
||||
* would split its suppression and preference state — one of the two would then be suppressed and
|
||||
* the other would keep delivering.
|
||||
*/
|
||||
// externalOutboundAllowed because ContactPointProtector is bound to an adapter and is called from
|
||||
// here directly. The call is local AES-GCM rather than a network hop, but the declaration is about
|
||||
// what this use case reaches for, not about how far the call travels — and routing it through a
|
||||
// helper to keep the flag false would defeat the check rather than satisfy it.
|
||||
@UseCaseCapability(
|
||||
transactionMode = TransactionMode.WRITE,
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
|
||||
externalOutboundAllowed = true)
|
||||
@RequiresPermission("notification:submit")
|
||||
public class AcceptNotificationApplicationUseCase implements AcceptNotificationUseCase {
|
||||
|
||||
private final SubmitNotificationUseCase submit;
|
||||
private final ContactPointStorePort contactPoints;
|
||||
private final ContactPointProtector protector;
|
||||
private final TenantContextPort tenants;
|
||||
private final NotificationIdGeneratorPort ids;
|
||||
private final TransactionPort transactions;
|
||||
private final Clock clock;
|
||||
|
||||
/**
|
||||
* Creates the use case.
|
||||
*
|
||||
* @param submit the plan-shaped submission this delegates to once a contact point exists
|
||||
* @param contactPoints the contact point directory
|
||||
* @param protector the encryption and keyed-lookup boundary
|
||||
* @param tenants the ambient tenant, used when the command names none
|
||||
* @param ids the identifier source; a use case never mints an identifier itself
|
||||
* @param transactions the write boundary this use case owns for registration
|
||||
* @param clock the time source for registration timestamps
|
||||
*/
|
||||
public AcceptNotificationApplicationUseCase(
|
||||
SubmitNotificationUseCase submit,
|
||||
ContactPointStorePort contactPoints,
|
||||
ContactPointProtector protector,
|
||||
TenantContextPort tenants,
|
||||
NotificationIdGeneratorPort ids,
|
||||
TransactionPort transactions,
|
||||
Clock clock) {
|
||||
this.submit = Objects.requireNonNull(submit, "submit");
|
||||
this.contactPoints = Objects.requireNonNull(contactPoints, "contactPoints");
|
||||
this.protector = Objects.requireNonNull(protector, "protector");
|
||||
this.tenants = Objects.requireNonNull(tenants, "tenants");
|
||||
// ids.nextId(), not UUID.randomUUID(). The identifier contract puts identifier generation
|
||||
// behind
|
||||
// a port so it is time-ordered and substitutable; a use case that mints its own produces random
|
||||
// v4 values whose index locality is the opposite of what the stores are built for.
|
||||
this.ids = Objects.requireNonNull(ids, "ids");
|
||||
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationReceipt handle(AcceptNotificationCommand command) {
|
||||
Objects.requireNonNull(command, "command");
|
||||
|
||||
TenantId tenantId = command.tenantId().map(TenantId::new).orElseGet(tenants::currentTenant);
|
||||
ContactPointValue value = parse(command.channel(), command.address());
|
||||
ContactPointId contactPointId = registerOrReuse(tenantId, command.recipientRef(), value);
|
||||
|
||||
NotificationPlan plan =
|
||||
new NotificationPlan(
|
||||
tenantId,
|
||||
new IdempotencyKey(command.idempotencyKey().orElseGet(() -> derivedKey(command))),
|
||||
command.category(),
|
||||
new TemplateSelection(
|
||||
command.templateId(), command.templateVersion(), command.locale()),
|
||||
command.variables(),
|
||||
List.of(
|
||||
new RecipientSpec(
|
||||
command.recipientRef(),
|
||||
Optional.of(command.locale()),
|
||||
Optional.empty(),
|
||||
List.of(new ContactPointSelector(command.channel(), contactPointId)),
|
||||
Optional.empty())),
|
||||
new ExplicitChannel(command.channel()),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
new CorrelationId(ids.nextId().toString()),
|
||||
Map.of());
|
||||
|
||||
return submit.handle(new SubmitNotificationCommand(plan));
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored contact point for this address, registering it if it is not already there.
|
||||
*
|
||||
* <p>Looked up by keyed fingerprint rather than by address, because the store holds no addresses
|
||||
* to compare against — only ciphertext and the fingerprint.
|
||||
*/
|
||||
private ContactPointId registerOrReuse(
|
||||
TenantId tenantId, String recipientRef, ContactPointValue value) {
|
||||
String fingerprint = protector.fingerprint(value);
|
||||
Optional<ContactPointRecord> existing = contactPoints.findByFingerprint(tenantId, fingerprint);
|
||||
if (existing.isPresent()) {
|
||||
return existing.get().id();
|
||||
}
|
||||
|
||||
ProtectedContactPoint protectedValue = protector.protect(value);
|
||||
Instant now = clock.instant();
|
||||
ContactPointRecord record =
|
||||
new ContactPointRecord(
|
||||
new ContactPointId(ids.nextId()),
|
||||
tenantId,
|
||||
recipientRef,
|
||||
value.type(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
"default",
|
||||
protectedValue,
|
||||
// UNVERIFIED, not ACTIVE. Someone submitting to an address is not evidence that the
|
||||
// address belongs to the recipient, and a self-registering ACTIVE contact point would
|
||||
// make the verification state mean nothing.
|
||||
ContactPointStatus.UNVERIFIED,
|
||||
false,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
now,
|
||||
now);
|
||||
return transactions.inWrite(() -> contactPoints.save(record)).id();
|
||||
}
|
||||
|
||||
/**
|
||||
* An idempotency key for a caller that supplied none.
|
||||
*
|
||||
* <p>Derived from the request's own identity — recipient, channel, template and variables — so
|
||||
* two retries of the same request collapse and two different requests do not. It deliberately
|
||||
* excludes the address: the key is stored, and the fingerprint of the recipient reference is
|
||||
* enough to tell two recipients apart without putting a contact point in an idempotency column.
|
||||
*/
|
||||
private static String derivedKey(AcceptNotificationCommand command) {
|
||||
return Integer.toHexString(
|
||||
Objects.hash(
|
||||
command.recipientRef(),
|
||||
command.channel(),
|
||||
command.templateId(),
|
||||
command.templateVersion(),
|
||||
command.variables()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel's contact point value for this address.
|
||||
*
|
||||
* <p>Push channels are absent on purpose: a device token or a Web Push subscription is not a
|
||||
* string a caller types, and accepting one here would mean parsing a credential out of a text
|
||||
* field. Those channels register through a directory, and this port refuses rather than guessing.
|
||||
*/
|
||||
private static ContactPointValue parse(Channel channel, String address) {
|
||||
return switch (channel) {
|
||||
case EMAIL -> EmailAddress.parse(address);
|
||||
case SMS -> new PhoneNumber(address);
|
||||
case IN_APP -> new InAppRecipientRef(address);
|
||||
case PUSH, WEB_PUSH, WEBHOOK ->
|
||||
throw new IllegalArgumentException(
|
||||
"channel "
|
||||
+ channel
|
||||
+ " addresses a registered contact point rather than a literal value; register it "
|
||||
+ "and submit with a contact point id");
|
||||
};
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -25,7 +25,7 @@ import java.util.Objects;
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||
@RequiresPermission("notification:cancel")
|
||||
public final class CancelNotificationApplicationUseCase implements CancelNotificationUseCase {
|
||||
public class CancelNotificationApplicationUseCase implements CancelNotificationUseCase {
|
||||
|
||||
private final NotificationSubmissionService service;
|
||||
private final TransactionPort transactions;
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -23,7 +23,7 @@ import java.util.Objects;
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
||||
@RequiresPermission("notification:read")
|
||||
public final class GetNotificationApplicationUseCase implements GetNotificationUseCase {
|
||||
public class GetNotificationApplicationUseCase implements GetNotificationUseCase {
|
||||
|
||||
private final NotificationSubmissionService service;
|
||||
private final TransactionPort transactions;
|
||||
|
||||
+60
-8
@@ -1,5 +1,6 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationVariable;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome;
|
||||
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
|
||||
@@ -25,6 +26,7 @@ import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -48,7 +50,19 @@ public final class NotificationDispatchService {
|
||||
private final DispatchOutcomeRecorder recorder;
|
||||
private final ProviderDispatchGatewayPort gateway;
|
||||
private final TemplateRendererRegistry renderers;
|
||||
private final NotificationVariablesCodecPort variables;
|
||||
|
||||
/**
|
||||
* The encoder that wrote the stored variables payload, used to read it back.
|
||||
*
|
||||
* <p>Replaces an injected {@code NotificationVariablesCodecPort}. That port decodes JSON and the
|
||||
* payload is the canonical length-framed form, so the two never agreed — a mismatch invisible
|
||||
* until a provider existed to dispatch to. Stateless and final, constructed here like the
|
||||
* submission service constructs its own: a second implementation of a canonical encoding is the
|
||||
* defect, not the extension point.
|
||||
*/
|
||||
private final CanonicalNotificationPlanEncoder planEncoder =
|
||||
new CanonicalNotificationPlanEncoder();
|
||||
|
||||
private final RecipientLeaseStorePort leases;
|
||||
private final TransactionPort transactions;
|
||||
private final Clock clock;
|
||||
@@ -66,7 +80,6 @@ public final class NotificationDispatchService {
|
||||
DispatchOutcomeRecorder recorder,
|
||||
ProviderDispatchGatewayPort gateway,
|
||||
TemplateRendererRegistry renderers,
|
||||
NotificationVariablesCodecPort variables,
|
||||
RecipientLeaseStorePort leases,
|
||||
TransactionPort transactions,
|
||||
Clock clock,
|
||||
@@ -82,7 +95,6 @@ public final class NotificationDispatchService {
|
||||
this.recorder = Objects.requireNonNull(recorder, "recorder");
|
||||
this.gateway = Objects.requireNonNull(gateway, "gateway");
|
||||
this.renderers = Objects.requireNonNull(renderers, "renderers");
|
||||
this.variables = Objects.requireNonNull(variables, "variables");
|
||||
this.leases = Objects.requireNonNull(leases, "leases");
|
||||
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
@@ -101,7 +113,7 @@ public final class NotificationDispatchService {
|
||||
|
||||
Optional<Work> loaded = transactions.inRead(() -> load(lease));
|
||||
if (loaded.isEmpty()) {
|
||||
leases.release(lease);
|
||||
releaseLease(lease);
|
||||
return;
|
||||
}
|
||||
Work work = loaded.get();
|
||||
@@ -109,7 +121,7 @@ public final class NotificationDispatchService {
|
||||
RoutingDecision decision = routing.next(routingContext(work, now));
|
||||
if (decision.selected().isEmpty()) {
|
||||
applyRoutingStop(work, decision);
|
||||
leases.release(lease);
|
||||
releaseLease(lease);
|
||||
return;
|
||||
}
|
||||
RouteCandidate route = decision.selected().get();
|
||||
@@ -126,7 +138,7 @@ public final class NotificationDispatchService {
|
||||
transactions.inWrite(
|
||||
() -> recipients.transition(work.recipient().id(), blocked.state(), Optional.empty()));
|
||||
refreshStatus(work);
|
||||
leases.release(lease);
|
||||
releaseLease(lease);
|
||||
return;
|
||||
}
|
||||
ContactPointRecord contactPoint = ((DispatchGuardOutcome.Proceed) guard).contactPoint();
|
||||
@@ -162,7 +174,7 @@ public final class NotificationDispatchService {
|
||||
|
||||
RetryDecision next = retryPolicy.decide(retryContext(work, recorded, result, profile));
|
||||
applyNextAction(work, recorded, next, clock.instant());
|
||||
leases.release(lease);
|
||||
releaseLease(lease);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,10 +285,50 @@ public final class NotificationDispatchService {
|
||||
work.request().template(),
|
||||
route.channel(),
|
||||
work.recipient().locale().orElse(work.request().template().locale()),
|
||||
variables.decode(work.request().variablesPayload()),
|
||||
// Decoded by the encoder that wrote it, not by a JSON codec. The stored payload is
|
||||
// the canonical form — the same bytes the fingerprint was taken over — and handing
|
||||
// it to a JSON parser failed on every dispatch: with no variables it is the empty
|
||||
// string, which Jackson rejects, and with variables it is not JSON at all.
|
||||
plainVariables(work.request().variablesPayload()),
|
||||
work.recipient().timeZone()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the lease inside a transaction, because releasing it is a write.
|
||||
*
|
||||
* <p>All four release points called the store directly, outside any boundary, and the JPA update
|
||||
* behind it needs one — so the release threw {@code TransactionRequiredException} and the lease
|
||||
* was left to expire instead. That cost a lease duration of throughput per delivery and, worse,
|
||||
* made a delivery that had already succeeded log as a failed dispatch: the exception is raised
|
||||
* after the provider has accepted the message.
|
||||
*
|
||||
* <p>Its own boundary rather than an enclosing one. The release must commit whatever happened
|
||||
* before it — a delivery that reached the provider must not have its record rolled back because
|
||||
* the release failed.
|
||||
*
|
||||
* @param lease the lease to release
|
||||
*/
|
||||
private void releaseLease(RecipientLease lease) {
|
||||
transactions.inWrite(
|
||||
() -> {
|
||||
leases.release(lease);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored variables, as the plain types a renderer consumes.
|
||||
*
|
||||
* @param payload the canonical variables payload
|
||||
* @return the variables, empty when the request carried none
|
||||
*/
|
||||
private Map<String, Object> plainVariables(String payload) {
|
||||
Map<String, NotificationVariable> decoded = planEncoder.decodeVariables(payload);
|
||||
Map<String, Object> plain = new java.util.LinkedHashMap<>(decoded.size());
|
||||
decoded.forEach((name, value) -> plain.put(name, value.plain()));
|
||||
return java.util.Collections.unmodifiableMap(plain);
|
||||
}
|
||||
|
||||
private Optional<Work> load(RecipientLease lease) {
|
||||
return recipients
|
||||
.find(lease.recipientDeliveryId())
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.capability.Idempotency;
|
||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||
import dev.caskeleton.application.notification.platform.port.in.PublishNotificationTemplateCommand;
|
||||
import dev.caskeleton.application.notification.platform.port.in.PublishNotificationTemplateUseCase;
|
||||
import dev.caskeleton.application.notification.platform.template.NotificationTemplateVersion;
|
||||
import dev.caskeleton.application.notification.platform.template.TemplateContentDefinition;
|
||||
import dev.caskeleton.application.notification.platform.template.TemplateRegistry;
|
||||
import dev.caskeleton.application.notification.platform.template.TemplateSlot;
|
||||
import dev.caskeleton.application.notification.platform.template.TemplateStatus;
|
||||
import dev.caskeleton.application.notification.platform.template.VariableSchema;
|
||||
import dev.caskeleton.application.security.RequiresPermission;
|
||||
import dev.caskeleton.application.transaction.TransactionMode;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Publishes an immutable template version.
|
||||
*
|
||||
* <p>The digest is computed here, over a canonical rendering of the slots, and it is what makes a
|
||||
* published version immutable in a checkable way rather than by convention: two publications of the
|
||||
* same content produce the same digest, and any edit produces a different one.
|
||||
*
|
||||
* <p>It lives in {@code dispatch} rather than {@code template}, with the other {@code
|
||||
* *ApplicationUseCase} classes: {@code dispatch -> template} is a registered package edge and the
|
||||
* reverse is not, because a template package that reaches back into dispatch cannot be understood
|
||||
* or extracted without it.
|
||||
*
|
||||
* <p>Canonical means slot order is fixed by the enum's declaration order and each entry is length-
|
||||
* prefixed. Concatenating slot sources in map order would hash {@code SUBJECT="ab", BODY="c"} and
|
||||
* {@code SUBJECT="a", BODY="bc"} alike, which is a content change the digest would call identical.
|
||||
*/
|
||||
// externalOutboundAllowed because MessageDigestPort is adapter-bound and called from here; see
|
||||
// AcceptNotificationApplicationUseCase for why the flag states the reach rather than the distance.
|
||||
@UseCaseCapability(
|
||||
transactionMode = TransactionMode.WRITE,
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
|
||||
externalOutboundAllowed = true)
|
||||
@RequiresPermission("notification-template:publish")
|
||||
public class PublishNotificationTemplateApplicationUseCase
|
||||
implements PublishNotificationTemplateUseCase {
|
||||
|
||||
private final TemplateRegistry templates;
|
||||
private final MessageDigestPort digests;
|
||||
private final TransactionPort transactions;
|
||||
|
||||
/**
|
||||
* Creates the use case.
|
||||
*
|
||||
* @param templates the registry this publishes into
|
||||
* @param digests the hashing boundary, so application-core names no crypto provider
|
||||
* @param transactions the write boundary this use case owns
|
||||
*/
|
||||
public PublishNotificationTemplateApplicationUseCase(
|
||||
TemplateRegistry templates, MessageDigestPort digests, TransactionPort transactions) {
|
||||
this.templates = Objects.requireNonNull(templates, "templates");
|
||||
this.digests = Objects.requireNonNull(digests, "digests");
|
||||
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationTemplateVersion handle(PublishNotificationTemplateCommand command) {
|
||||
Objects.requireNonNull(command, "command");
|
||||
|
||||
TemplateContentDefinition content = new TemplateContentDefinition(command.slots());
|
||||
VariableSchema schema =
|
||||
new VariableSchema(
|
||||
VariableSchema.NONE.schemaJson(),
|
||||
command.requiredVariables(),
|
||||
command.sensitiveVariables());
|
||||
|
||||
NotificationTemplateVersion version =
|
||||
new NotificationTemplateVersion(
|
||||
command.templateId(),
|
||||
command.version(),
|
||||
command.channel(),
|
||||
command.locale(),
|
||||
command.fallbackLocale(),
|
||||
schema,
|
||||
content,
|
||||
TemplateStatus.PUBLISHED,
|
||||
contentDigest(command.slots()));
|
||||
|
||||
transactions.inWrite(
|
||||
() -> {
|
||||
templates.publish(version);
|
||||
return null;
|
||||
});
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-256 over a canonical rendering of the slots.
|
||||
*
|
||||
* <p>Order comes from the enum rather than the map so it does not depend on how the caller built
|
||||
* it, and every field is length-prefixed so no two different slot sets can serialize alike.
|
||||
*/
|
||||
private String contentDigest(Map<TemplateSlot, String> slots) {
|
||||
StringBuilder canonical = new StringBuilder();
|
||||
for (TemplateSlot slot : List.of(TemplateSlot.values())) {
|
||||
String source = slots.get(slot);
|
||||
if (source == null) {
|
||||
continue;
|
||||
}
|
||||
canonical
|
||||
.append(slot.name())
|
||||
.append(':')
|
||||
.append(source.length())
|
||||
.append(':')
|
||||
.append(source)
|
||||
.append('\n');
|
||||
}
|
||||
return HexFormat.of()
|
||||
.formatHex(digests.sha256(canonical.toString().getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -25,7 +25,7 @@ import java.util.Objects;
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||
@RequiresPermission("notification:submit")
|
||||
public final class ScheduleNotificationApplicationUseCase implements ScheduleNotificationUseCase {
|
||||
public class ScheduleNotificationApplicationUseCase implements ScheduleNotificationUseCase {
|
||||
|
||||
private final NotificationSubmissionService service;
|
||||
private final TransactionPort transactions;
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ import java.util.Objects;
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||
@RequiresPermission("notification:submit")
|
||||
public final class SubmitNotificationApplicationUseCase implements SubmitNotificationUseCase {
|
||||
public class SubmitNotificationApplicationUseCase implements SubmitNotificationUseCase {
|
||||
|
||||
private final NotificationSubmissionService service;
|
||||
private final TransactionPort transactions;
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package dev.caskeleton.application.notification.platform.port.in;
|
||||
|
||||
import dev.caskeleton.application.command.Command;
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationVariable;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Accept a notification addressed by contact point value rather than by stored identity.
|
||||
*
|
||||
* <p>{@code SubmitNotificationCommand} carries a full {@link
|
||||
* dev.caskeleton.application.notification.platform.api.NotificationPlan}, whose recipients
|
||||
* reference contact points that are already registered and encrypted. That is the right shape for a
|
||||
* caller that owns a contact point directory, and an impossible one for a caller that has an
|
||||
* address: the platform has no surface to register a contact point through, so nothing could
|
||||
* construct a plan this way.
|
||||
*
|
||||
* <p>This command closes that gap without weakening the model — the address is protected and stored
|
||||
* as a contact point before any plan exists, so the address itself never reaches a plan, a
|
||||
* fingerprint or a stored row in clear text.
|
||||
*
|
||||
* @param tenantId the tenant, or empty to use the ambient tenant context
|
||||
* @param recipientRef the caller's stable reference for the recipient; never the address itself
|
||||
* @param channel the channel to send on
|
||||
* @param address the contact point value, in its channel's textual form
|
||||
* @param templateId the template a submission pins
|
||||
* @param templateVersion the version pinned alongside it
|
||||
* @param locale the locale to render in
|
||||
* @param variables the notification-wide variables
|
||||
* @param idempotencyKey the caller's key, or empty to derive one from the request
|
||||
* @param category the routing/reporting category
|
||||
*/
|
||||
public record AcceptNotificationCommand(
|
||||
Optional<String> tenantId,
|
||||
String recipientRef,
|
||||
Channel channel,
|
||||
String address,
|
||||
String templateId,
|
||||
long templateVersion,
|
||||
Locale locale,
|
||||
Map<String, NotificationVariable> variables,
|
||||
Optional<String> idempotencyKey,
|
||||
String category)
|
||||
implements Command {
|
||||
|
||||
public AcceptNotificationCommand {
|
||||
Objects.requireNonNull(tenantId, "tenantId");
|
||||
Objects.requireNonNull(channel, "channel");
|
||||
Objects.requireNonNull(locale, "locale");
|
||||
Objects.requireNonNull(idempotencyKey, "idempotencyKey");
|
||||
variables = Map.copyOf(Objects.requireNonNull(variables, "variables"));
|
||||
if (recipientRef == null || recipientRef.isBlank()) {
|
||||
throw new IllegalArgumentException("recipientRef");
|
||||
}
|
||||
// The address is not echoed in any failure message here or below. An invalid-address error that
|
||||
// quotes the address puts a contact point into a log line, which is the one place the whole
|
||||
// encryption-at-rest design is trying to keep it out of.
|
||||
if (address == null || address.isBlank()) {
|
||||
throw new IllegalArgumentException("address");
|
||||
}
|
||||
if (templateId == null || templateId.isBlank()) {
|
||||
throw new IllegalArgumentException("templateId");
|
||||
}
|
||||
if (templateVersion <= 0) {
|
||||
throw new IllegalArgumentException("templateVersion");
|
||||
}
|
||||
if (category == null || category.isBlank()) {
|
||||
throw new IllegalArgumentException("category");
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.application.notification.platform.port.in;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationReceipt;
|
||||
import dev.caskeleton.application.usecase.CommandUseCase;
|
||||
|
||||
/**
|
||||
* Accepting a notification for a recipient identified by address.
|
||||
*
|
||||
* <p>The transport-facing entry point of the platform. {@link SubmitNotificationUseCase} stays the
|
||||
* contract for a caller that already holds registered contact points.
|
||||
*/
|
||||
public interface AcceptNotificationUseCase
|
||||
extends CommandUseCase<AcceptNotificationCommand, NotificationReceipt> {}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package dev.caskeleton.application.notification.platform.port.in;
|
||||
|
||||
import dev.caskeleton.application.command.Command;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.template.TemplateSlot;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Publish one immutable template version.
|
||||
*
|
||||
* <p>The content digest is deliberately absent: it is computed from {@code slots} by the use case,
|
||||
* not supplied by the caller. A caller-supplied digest is a caller-supplied claim about content the
|
||||
* platform is about to store, and the two can disagree.
|
||||
*
|
||||
* @param templateId the stable identity a submission pins
|
||||
* @param version the version a submission pins alongside the id; versions are never edited
|
||||
* @param channel the channel this version renders for
|
||||
* @param locale the locale this version is written in
|
||||
* @param fallbackLocale the locale to resolve to when a request asks for one this version lacks
|
||||
* @param slots the engine-neutral sources, by slot
|
||||
* @param requiredVariables variables a submission must supply
|
||||
* @param sensitiveVariables variables that must be masked in logs, previews and failures
|
||||
*/
|
||||
public record PublishNotificationTemplateCommand(
|
||||
String templateId,
|
||||
long version,
|
||||
Channel channel,
|
||||
Locale locale,
|
||||
Optional<Locale> fallbackLocale,
|
||||
Map<TemplateSlot, String> slots,
|
||||
Set<String> requiredVariables,
|
||||
Set<String> sensitiveVariables)
|
||||
implements Command {
|
||||
|
||||
public PublishNotificationTemplateCommand {
|
||||
Objects.requireNonNull(templateId, "templateId");
|
||||
Objects.requireNonNull(channel, "channel");
|
||||
Objects.requireNonNull(locale, "locale");
|
||||
Objects.requireNonNull(fallbackLocale, "fallbackLocale");
|
||||
slots = Map.copyOf(Objects.requireNonNull(slots, "slots"));
|
||||
requiredVariables = Set.copyOf(Objects.requireNonNull(requiredVariables, "requiredVariables"));
|
||||
sensitiveVariables =
|
||||
Set.copyOf(Objects.requireNonNull(sensitiveVariables, "sensitiveVariables"));
|
||||
if (templateId.isBlank()) {
|
||||
throw new IllegalArgumentException("templateId");
|
||||
}
|
||||
if (version <= 0) {
|
||||
throw new IllegalArgumentException("version");
|
||||
}
|
||||
if (slots.isEmpty()) {
|
||||
throw new IllegalArgumentException("slots must not be empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.application.notification.platform.port.in;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.template.NotificationTemplateVersion;
|
||||
import dev.caskeleton.application.usecase.CommandUseCase;
|
||||
|
||||
/**
|
||||
* Publishing a template version.
|
||||
*
|
||||
* <p>A submission pins a template id and version and the platform refuses one it cannot resolve, so
|
||||
* without this port a deployment has a notification platform it can never submit to. That was the
|
||||
* state until NTF-INT-008: the registry port had {@code publish}, and nothing in the application or
|
||||
* any transport ever called it.
|
||||
*/
|
||||
public interface PublishNotificationTemplateUseCase
|
||||
extends CommandUseCase<PublishNotificationTemplateCommand, NotificationTemplateVersion> {}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package dev.caskeleton.application.notification.platform.security;
|
||||
|
||||
/**
|
||||
* Protects the accepted request's template variables at rest (NTF-INT-007).
|
||||
*
|
||||
* <p>The accept path stored {@code variablesPayload} verbatim. Template variables are the message's
|
||||
* own content — a reset code, an order total, a delivery address — so the row held caller-supplied
|
||||
* sensitive data in plaintext for as long as the request was retained.
|
||||
*
|
||||
* <p>The alternative branch, restricting the variable types to non-sensitive values, was rejected
|
||||
* on evidence: {@code NotificationVariable.TextValue} holds arbitrary text because that is what a
|
||||
* notification is for, so the restriction would be either unenforceable or would delete the
|
||||
* capability. The analysis is in {@code docs/notification/at-rest-threat-model.md}.
|
||||
*
|
||||
* <p>A port rather than a utility, because the algorithm and the key store are infrastructure and
|
||||
* the accept path is not. The application decides <em>that</em> the payload is protected; the
|
||||
* adapter decides how.
|
||||
*
|
||||
* <h2>What the envelope must carry</h2>
|
||||
*
|
||||
* <p><b>Its key id.</b> Without one, rotation is a one-way door: the moment the active key changes,
|
||||
* every row written under the old one is unreadable and nothing can tell you which key it needed.
|
||||
* The callback protection already in this repository stores nonce and ciphertext with no key id,
|
||||
* and that is the gap this port exists not to repeat.
|
||||
*
|
||||
* <h2>What a failed decryption means</h2>
|
||||
*
|
||||
* <p>{@link #reveal} throws {@link NotificationPayloadUnreadableException} rather than returning
|
||||
* empty. A request whose variables cannot be read cannot be rendered, and a caller that receives an
|
||||
* empty payload will send a notification with every variable missing — a message that says "Hello ,
|
||||
* your code is " to a real person. Failing loudly leaves the row for an operator to reconcile;
|
||||
* failing quietly delivers the failure to the recipient.
|
||||
*/
|
||||
public interface NotificationPayloadProtection {
|
||||
|
||||
/**
|
||||
* Encrypts a payload for storage.
|
||||
*
|
||||
* @param plaintext the canonical variables payload
|
||||
* @return the envelope: version, key id, nonce and ciphertext
|
||||
*/
|
||||
byte[] protect(byte[] plaintext);
|
||||
|
||||
/**
|
||||
* Decrypts a stored payload.
|
||||
*
|
||||
* @param envelope what {@link #protect} produced, possibly under a retired key
|
||||
* @return the canonical variables payload
|
||||
* @throws NotificationPayloadUnreadableException when the envelope is malformed, its key is
|
||||
* unknown, or authentication fails
|
||||
*/
|
||||
byte[] reveal(byte[] envelope);
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package dev.caskeleton.application.notification.platform.security;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A stored payload that cannot be decrypted (NTF-INT-007).
|
||||
*
|
||||
* <p>Deliberately not an empty result. A request whose variables cannot be read cannot be rendered,
|
||||
* and a caller handed an empty payload renders every variable as nothing — a message that says
|
||||
* "Hello , your code is " to a real person. That is the failure being delivered rather than
|
||||
* reported.
|
||||
*
|
||||
* <p>Carries the key id the envelope asked for, when the envelope was well-formed enough to name
|
||||
* one, because the operator's next question is always which key is missing. It never carries the
|
||||
* ciphertext, the plaintext or any part of either.
|
||||
*/
|
||||
public final class NotificationPayloadUnreadableException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final transient String keyId;
|
||||
|
||||
/**
|
||||
* Creates the failure.
|
||||
*
|
||||
* @param reason what was wrong, in terms an operator can act on
|
||||
* @param keyId the key the envelope named, or {@code "unknown"} when it named none
|
||||
* @param cause the underlying failure, whose message is never surfaced to a caller
|
||||
*/
|
||||
public NotificationPayloadUnreadableException(String reason, String keyId, Throwable cause) {
|
||||
super(reason + " (key " + Objects.requireNonNull(keyId, "keyId") + ")", cause);
|
||||
this.keyId = keyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key the envelope named.
|
||||
*
|
||||
* @return the key id, or {@code "unknown"}
|
||||
*/
|
||||
public String keyId() {
|
||||
return keyId;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -32,7 +32,7 @@ import java.util.function.Supplier;
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
|
||||
externalOutboundAllowed = true)
|
||||
public final class PublishPendingOutboxEventsUseCase
|
||||
public class PublishPendingOutboxEventsUseCase
|
||||
implements CommandUseCase<PublishPendingOutboxEventsCommand, OutboxRelayResult> {
|
||||
|
||||
private final OutboxStorePort store;
|
||||
|
||||
Reference in New Issue
Block a user