feat: jpa, messaging, notification, mongo, graphql 어댑터터 리펙토링

This commit is contained in:
DongHyeonka
2026-08-18 10:59:56 +09:00
parent 2f5d2fc219
commit e98b56eb03
372 changed files with 25131 additions and 20357 deletions
@@ -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;
@@ -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> {
@@ -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> {
@@ -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;
@@ -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> {
@@ -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;
@@ -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;
@@ -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> {
@@ -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> {
@@ -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> {
@@ -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> {
@@ -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"));
@@ -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;
@@ -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);
}
}
@@ -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 {
@@ -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");
};
}
}
@@ -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;
@@ -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;
@@ -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())
@@ -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)));
}
}
@@ -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;
@@ -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;
@@ -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");
}
}
}
@@ -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> {}
@@ -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");
}
}
}
@@ -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> {}
@@ -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);
}
@@ -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;
}
}
@@ -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;